UniswapV2-交易机制

参考资料:

https://updraft.cyfrin.io/courses/uniswap-v2/

https://github.com/kpyaoqi/UniswapV2_Chinese

概念详解

Uniswap 是一个去中心化的交易协议,基于以太坊区块链,允许用户在无需中介的情况下进行代币交换(Token Swap)。它是自动化做市商(AMM, Automated Market Maker)的一个典型实现,其核心机制是 恒定乘积公式,即:xy=kx \cdot y = k,其中:

  • xxyy 分别表示池中两种代币的数量;
  • kk 是一个固定值,表示池子的恒定流动性。

Uniswap的特点:

  1. 无需订单簿:Uniswap 不依赖传统的订单簿交易模式,用户无需与另一方直接匹配,而是与流动性池交互。
  2. 去中心化:Uniswap 的合约是开源且去中心化的,任何人都可以自由添加流动性或创建新的交易对。

核心机制

  1. 流动性(L, Liquidity):

    • 流动性越大,交易曲线越平缓。这意味着交易者能够以更好的价格进行代币交换,减少滑点(Slippage)。
    • 流动性提供者通过存入等价值的两种代币,为池子注入流动性,并从每笔交易中收取交易费用作为奖励。
  2. 恒定乘积公式:

    • 在公式 xy=kx \cdot y = k 中,无论池中代币的比例如何变化,乘积 kk 的值始终保持不变。这意味着随着一种代币的增加,另一种代币的数量必须减少以维持平衡。这种机制确保了另一种代币的数量会根据交易动态调整。
    • 例如,当一个用户向池中加入一种代币(例如xx)时,另一种代币(例如yy)的数量会减少,从而保持公式的平衡。

案例分析

假设池中初始的代币分布为 x=200x=200y=200y=200,恒定乘积为 k=200200=40,000k=200 \cdot 200=40,000

用户加入 200200 个代币 xxdx=200dx=200),此时池中的 xx 总量变为 400400,根据公式:y=kx=40,000400=100y = \frac{k}{x} = \frac{40,000}{400} = 100

所以代币 yy 减少 100100dy=100dy=-100),意味着用户用 200 个 xx 代币换得了 100 个 yy 代币。

image-20241119094141819

假设初始池中的代币分布为 x=400x=400y=400y=400,恒定乘积为 k=400400=160,000k=400 \cdot 400=160,000

用户再加入 200200 个代币 xxdx=200dx=200),此时池中的 xx 总量变为 600600,根据公式:y=kx=160,000600266.67y = \frac{k}{x} = \frac{160,000}{600} \approx 266.67

所以代币 yy 减少约 133.33133.33dy133.33dy \approx -133.33),意味着此时用户用 200 个 xx 代币换得了约 133 个 yy 代币。

image-20241119094228454

when L is a large number, traders will get a better deal for the same amount of token that they put in

结论:随着流动性(LL)的增加,交易对价格的变化曲线趋于平滑,即 当池中资金量较大时,同样的交易量会导致更小的价格波动,从而让交易者获得更好的交易价格。

代码仓库

Uniswap V2 的架构分为两部分:

  1. v2-core 仓库:包含核心的智能合约逻辑(如 Pair 合约),可以直接交互,但不建议直接交互;

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

  2. v2-periphery 仓库:包含辅助合约(如 Router 合约),负责和用户交互,会帮我们调用v2-core。

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

核心组件

Uniswap V2 主要由三个核心组件构成:

  1. Factory 合约:

    • 用于管理所有交易对(Token Pair)的工厂合约。
    • 功能:创建新的交易对,并记录每个交易对的地址。
    • 作用:为每个交易对分配一个唯一的合约实例。
  2. Router 合约:

    • 用户通过Router与 Uniswap 交互,用于实现代币交换、添加流动性、移除流动性等核心功能。
    • 功能:路由器合约将用户请求引导至对应的交易对合约中。
  3. Pair 合约:

    • 每个交易对都有独立的合约,用于存储流动性和管理代币交换。
    • 功能:负责维护池中代币的比例和恒定乘积关系,处理用户的交易和流动性操作。

image-20241121084354659

Swap Fee

在 Uniswap V2 中,每次代币交换都会收取一部分交易费用(Swap Fee),这部分费用是对流动性提供者的奖励。以下是具体的计算过程和公式推导:

没有 Swap Fee 的情况

对于恒定乘积公式 xy=kx \cdot y = k,用户提供 dxdx 数量的代币 xx,想要获得 dydy 数量的代币 yy。在不考虑交易费的情况下:

  • 初始状态(交易前):(x0,y0)(x_0, y_0)
  • 交易后状态:(x0+dx,y0dy)(x_0 + dx, y_0 - dy)

根据公式恒定乘积的要求:

(x0+dx)(y0dy)=x0y0=k(x_0 + dx)(y_0 - dy) = x_0 \cdot y_0 = k

推导得:

dy=y0dxx0+dxdy = \frac{y_0 \cdot dx}{x_0 + dx}

即用户可以获得的代币数量 dydy 由上式计算。

image-20241121084837315

考虑 Swap Fee 的情况

假设交易费率为 ff,则:

  • 用户实际提供的有效输入代币数量为 dx(1f)dx \cdot (1 - f)(扣除了 fdxf \cdot dx 作为手续费)。

交易后的恒定乘积公式变为:

(x0+dx(1f))(y0dy)=x0y0=k(x_0 + dx \cdot (1 - f))(y_0 - dy) = x_0 \cdot y_0 = k

整理后,用户实际获得的代币数量 dydy 为:

dy=dx(1f)y0x0+dx(1f)dy = \frac{dx \cdot (1 - f) \cdot y_0}{x_0 + dx \cdot (1 - f)}

举个具体的例子,假设:

  • 初始池中有 x0=6,000,000x_0 = 6,000,000 DAI 和 y0=3000y_0 = 3000 ETH;
  • 用户提供 dx=1000dx = 1000 DAI;
  • Swap Fee 费率 f=0.003f = 0.003(即 0.3%)。

代入公式计算:

dy=1000(10.003)30006,000,000+1000(10.003)dy = \frac{1000 \cdot (1 - 0.003) \cdot 3000}{6,000,000 + 1000 \cdot (1 - 0.003)}

dy=10000.99730006,000,000+9970.49841 ETHdy = \frac{1000 \cdot 0.997 \cdot 3000}{6,000,000 + 997} \approx 0.49841\ \text{ETH}

用户最终可以获得约 0.498410.49841 ETH。

image-20241121085141096

Swap 详解

Uniswap 的交易(Swap)分为两个主要模块:

  1. v2-core(核心逻辑):
    • 负责处理恒定乘积的自动做市商(AMM)机制,维护流动性池状态。
    • 包含主要合约:UniswapV2Pair.sol
    • 核心任务:
      • 确定代币的输入和输出数量。
      • 维护恒定乘积公式 x⋅y=kxy=k
      • 扣除手续费(默认 0.3%)。
      • 更新流动性池储备。
  2. v2-periphery(外围逻辑):
    • 提供用户友好的接口(Router 合约),实现复杂的多跳路径规划。
    • 包含主要合约:UniswapV2Router02.sol
    • 核心任务:
      • 路径规划(例如多跳交易 A -> B -> C)。
      • v2-corePair 合约交互,逐步调用 swap() 完成代币兑换。
      • 管理 ETH 和 WETH 的交互(例如将 ETH 转换为 WETH)。

过程概述

Swap 的场景大致分为两类

  1. 单次跳跃的直接 Swap
    • 用户直接将一种代币换成目标代币。
    • 示例:
      • WETH -> DAI(直接通过 WETH/DAI 流动性池完成)。
    • 函数调用:
      • 用户调用 Router 的 swapExactTokensForTokensswapExactETHForTokens
      • Router 调用单个流动性池的 Pair.swap() 完成交易。
  2. 多跳路径的 Swap
    • 用户通过多个流动性池进行兑换。
    • 示例:
      • WETH -> DAI -> MKR(通过 WETH/DAIDAI/MKR 两个流动性池)。
    • 函数调用:
      • 用户调用 Router 的 swapExactTokensForTokens
      • Router 会按路径依次调用多个流动性池的 Pair.swap(),逐步完成交易。

Swap 的逻辑可以分为以下几个关键阶段

  1. 用户调用 Router 合约发起交易
    • 用户调用 Router 的接口(如 swapExactETHForTokensswapExactTokensForTokens)。
    • Router 负责路径规划,计算代币数量,并与 Pair 合约交互。
  2. 路径规划与数量计算
    • Router 调用 getAmountsOut 计算每一步交易的输出数量,确定最终交易路径和滑点保护。
    • 如果是多跳交易,逐步规划每一步的输入和输出。
  3. 代币转移与准备
    • 如果是 ERC20 代币交易,Router 会将用户的代币直接转移到 Pair 合约(通常通过 safeTransfer)。
    • 如果是 ETH 交易,Router 会先通过 WETH 合约的 deposit 将 ETH 包装成 WETH。
  4. 调用 Pair 的 swap 函数完成交易
    • Router 按路径逐步调用 Pair 合约的 swap 函数。
    • Pair 根据恒定乘积公式计算输出代币数量,扣除手续费,更新储备,并将目标代币发送至下一个接收地址。
  5. 结果交付
    • 如果是多跳交易,最后一步输出的代币会发送至用户指定地址。

核心函数

Router 中的 Swap 函数

以下是 Router 合约中与 Swap 相关的函数及其作用:

Router 函数作用
swapExactETHForTokens用户用精确数量的 ETH 换取目标代币,输出代币数量 >= amountOutMin
swapExactTokensForTokens用户用精确数量的 ERC20 代币换取目标代币,输出代币数量 >= amountOutMin
swapTokensForExactTokens用户用尽可能少的输入代币换取精确数量的目标代币,输入代币数量 <= amountInMax
_swap内部函数,沿路径调用 Pair 的 swap 函数,完成多跳交易。
getAmountsOut根据给定的某种输入代币数量 amountIn 和路径 path,计算每一步交易的输出代币数量,用于估算最终输出代币数量。
getAmountsIn根据目标代币数量 amountOut 和路径 path,计算完成交易所需的输入代币数量,用于估算最小输入代币数量。

Pair 中的核心函数

以下是 Pair 合约中与 Swap 相关的函数及其作用:

Pair 函数作用
swap完成代币兑换操作,根据恒定乘积公式计算输出代币数量,扣除手续费,并将目标代币发送至接收地址。
getReserves返回当前流动性池中两种代币的储备量,用于计算价格和交易数量。
sync同步流动性池中的代币储备量(用于异常情况)。

单跳示例

WETH -> DAI ,在这个场景中,用户希望将 ETH 换成 DAI,而 ETH 是通过 Router 转换为 WETH 的。以下是每个步骤的详细执行逻辑【图中的第二步有问题,不是transferFrom,是deposit】:

image-20250123213931256

调用函数:Router.swapExactETHForTokens(uint amountOutMin, address[] calldata path, address to, uint deadline)

  • 输入参数

    • amountOutMin:用户期望收到的最小 DAI 数量(滑点保护);
    • path:交换路径,例:[WETH, DAI]
    • to:交易完成后,接收 DAI 的地址;
    • deadline:交易必须在此时间前完成,否则失败。
  • 步骤拆解:

    1. 计算路径上的输出 (getAmountsOut)

      • 根据用户输入的 amountIn 和兑换路径(path),计算每个池子中会输出多少代币。
      • 依赖公式:amountOut = reserveOut * amountIn / (reserveIn + amountIn)
      • 如果路径是 [TokenA, TokenB, TokenC] ,会计算:
        • TokenA -> TokenB 的输出数量;
        • 然后 TokenB -> TokenC 的输出数量。
    2. 收取 ETH 并包装为 WETH

    • Router 合约的 swapExactETHForTokens 函数调用 IWETH.deposit(),将用户发送的 ETH 转换为 WETH,存储在 Router 合约中。

    1. 转移 WETH 至 Pair 合约

      • Router 合约调用 IERC20(WETH).transfer(),将用户提供的 WETH 发送至对应的流动性池(DAI/WETH 池)。
    2. 调用 Pair 合约的 Swap 函数

      • Router 合约调用 Pair.swap(),通过 path 指定的路径,从 WETH 换取 DAI。
      • 核心逻辑Pair.swap()):
        • swap 函数会检查 x * y = k 恒定乘积公式,计算输入的 WETH 数量对应的输出 DAI 数量(扣除手续费)。
        • 更新池子的代币余额,并将用户的目标代币(DAI)发送至 to 地址。
    3. 将 DAI 转移至用户

      • Router 合约完成最后的转账操作,将从 Pair 池中获取的 DAI 转移到用户指定的地址。

核心调用路径

  1. 用户调用 Router.swapExactETHForTokens

    • Router 调用 IWETH.deposit 将 ETH 转为 WETH。
    • Router 将 WETH 转移至 Pair。
    • Router 调用 Pair.swap 执行代币兑换。
    • 最后将 DAI 发送到用户地址。
  2. Pair 合约的 swap 核心逻辑:

    • 调用 getReserves 获取当前池子的代币余额;
    • 根据恒定乘积公式计算输出代币数量;
    • 扣除 Swap Fee(默认 0.3%);
    • 更新储备量并转移目标代币。

swapExactETHForTokens 代码实现

// 根据确切数量的ETH兑换Token
function swapExactETHForTokens(
    uint amountOutMin, // 期望的最小输出Token数量(防止滑点)
    address[] calldata path, // 兑换路径,例如 [WETH, TokenA, TokenB]
    address to, // 接收最终Token的地址
    uint deadline // 交易过期时间
) external payable virtual override ensure(deadline) returns (uint[] memory amounts) {
    // 确保兑换路径的第一个代币是WETH,因为是用ETH兑换
    require(path[0] == WETH, 'UniswapV2Router: INVALID_PATH');

    // 通过UniswapV2Library计算兑换路径中每一步的预期输出数量
    amounts = UniswapV2Library.getAmountsOut(factory, msg.value, path);

    // 确保最终输出的Token数量大于等于用户设置的最小值
    require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');

    // 将用户发送的ETH转换为WETH(Wrapped ETH)
    IWETH(WETH).deposit{value: amounts[0]}();

    // 将WETH转移到第一个交易对(即WETH和path[1]的流动性池)
    assert(IWETH(WETH).transfer(UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]));

    // 调用内部函数_swap,完成路径中所有交易对的兑换
    _swap(amounts, path, to);
}

多跳示例

WETH -> DAI -> MKR,在这个场景中,用户希望通过两跳路径(WETH -> DAI -> MKR)完成交易。以下是每个步骤的详细执行逻辑:

image-20241121085601346

调用函数:Router.swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline)

  • 输入参数

    • amountIn:用户提供的 WETH 数量;
    • amountOutMin:用户期望的最小 MKR 数量;
    • path:交换路径,例:[WETH, DAI, MKR]
    • to:最终接收 MKR 的地址;
    • deadline:交易必须在此时间前完成。
  • 步骤拆解:

    1. 同上,先计算路径上的输出 (getAmountsOut),不再赘述
    2. 转移 WETH 至 Pair 合约
      • Router 合约首先调用 IERC20(WETH).safeTransferFrom(),将用户提供的 WETH 转移到 DAI/WETH 流动性池中。
      • 该函数会验证用户是否批准了 Router 使用指定数量的 WETH。
    3. 调用 Pair 合约的 Swap 函数
      • Router 合约调用 Pair.swap(),通过 path 指定的路径完成多跳交易。
    4. 第一跳交易:WETH -> DAI
      • Router 调用 Pair.swap() 执行第一步兑换。
      • Pair.swap 核心操作:
        • 调用 getReserves 获取 DAI/WETH 池的当前代币储备;
        • 根据恒定乘积公式计算用户提供的 WETH 可以换取多少 DAI;
        • 扣除交易手续费(0.3%);
        • 将换得的 DAI 发送至 DAI/MKR 池。
    5. 第二跳交易:DAI -> MKR
      • 第一跳交易完成后,Router 将获得的 DAI 发送至 DAI/MKR 流动性池。
      • 再次调用 Pair.swap(),根据 getReserves 和恒定乘积公式计算可以兑换的 MKR 数量;
      • 将最终获得的 MKR 发送至用户指定地址。

swapExactTokensForTokens 函数代码如下

    /**
     * @dev 根据确切的tokenA的数量兑换tokenB
     * @param amountIn 进行兑换的tokenA的数量
     * @param amountOutMin 愿意接受兑换后的最低tokenB数量,用于控制滑点
     * @param path 代币兑换路径数组。例如:
     *        [USDC, WETH, DAI] 表示 USDC -> WETH -> DAI
     *        [USDC, DAI] 表示直接 USDC -> DAI
     * @param to 接受兑换后获得tokenB的地址
     * @param deadline 交易允许最后执行时间
     * @return amounts 根据path路径获得每对交易对获得的token,最后一个为获得兑换后tokenB的数量
     */
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external virtual override ensure(deadline) returns (uint[] memory amounts) {
        // 调用了getAmountsOut,根据传入的tokenA的数量和path获得兑换后的amounts
        amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
        // 判断最终获得的tokenB的数量是否大于amountOutMin
        require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
        
        // UniswapV2Library.pairFor 是一个优化函数,它通过计算而不是通过调用factory合约来获取pair地址
        //避免了对 factory 合约的 SLOAD 操作,从而节省 gas,这是因为 Uniswap V2 使用确定性的地址创建方法,可以直接计算
        // 这里只传入path[0]和path[1]是因为我们只需要第一个交易对的地址,用于初始转账
        // 后续的交易对转账会在_swap函数中处理
        TransferHelper.safeTransferFrom(
            path[0],  // 第一个代币地址
            msg.sender,
            UniswapV2Library.pairFor(factory, path[0], path[1]),  // 计算第一个交易对的地址
            amounts[0]
        );
        
        // _swap函数会处理整个兑换路径,包括多个交易对之间的兑换
        _swap(amounts, path, to);
    }

核心调用路径

  1. 用户调用 Router.swapExactTokensForTokens

    • Router 将用户提供的 WETH 转移至 DAI/WETH 池。
    • Router 调用 Pair.swap 完成第一跳(WETH -> DAI)。
    • Router 将第一跳的输出(DAI)转移至 DAI/MKR 池。
    • Router 调用 Pair.swap 完成第二跳(DAI -> MKR)。
    • 最终将 MKR 转移至用户地址。
  2. Pair 合约的 swap 核心逻辑:

    • 每一跳都遵循 x * y = k 的恒定乘积公式,计算输出代币;
    • 扣除 0.3% 的 Swap Fee;
    • 更新储备量;
    • 转移输出代币。

代码详解

getAmountsOut

  • 作用:计算固定输入代币数量通过路径 path 转换后,最终可以得到多少目标代币。

  • 使用场景:当用户指定了输入代币数量(如 swapExactTokensForTokens)时,Router 会调用 getAmountsOut 来估算每一步的输出代币数量,并确保最终输出满足用户要求。

  • 实现逻辑

    1. 初始化数组:

      • 创建一个长度为 path.length 的数组 amounts,用于存储每一步的输出数量。
    2. 设置初始输入数量:

      • amounts[0] = amountIn,表示第一步的输入数量。
    3. 遍历路径:

      • 对于路径中的每一对代币(例如 TokenA -> TokenB),调用 getReserves 获取流动性池的储备量。

      • 使用 getAmountOut 函数计算当前步骤的输出数量,公式为:

        amountOut=amountIn×997×reserveOutreserveIn×1000+amountIn×997\text{amountOut} = \frac{\text{amountIn} \times 997 \times \text{reserveOut}}{\text{reserveIn} \times 1000 + \text{amountIn} \times 997}
    • 将计算结果存入 amounts 数组。
  1. 返回结果:

    • 返回 amounts 数组,表示每一步的输出数量。

代码实现如下

// 对任意数量的交易对执行链式getAmountOut计算
function getAmountsOut(address factory, uint amountIn, address[] memory path) internal view returns (uint[] memory amounts) {
    // 确保路径至少包含两个代币
    require(path.length >= 2, 'UniswapV2Library: INVALID_PATH');
    
    // 创建一个与路径长度相同的数组来存储每步交易的数量
    // 例如:path = [USDC, WETH, DAI]
    // amounts 将存储 [USDC数量, WETH数量, DAI数量]
    amounts = new uint[](path.length);
    
    // 设置初始输入数量
    amounts[0] = amountIn;
    
    // 遍历整个路径,计算每一步能得到的代币数量
    for (uint i; i < path.length - 1; i++) {
        // 获取当前交易对的储备量
        // 例如第一轮:获取USDC/WETH交易对的储备量
        // 例如第二轮:获取WETH/DAI交易对的储备量
        (uint reserveIn, uint reserveOut) = getReserves(factory, path[i], path[i + 1]);
        
        // 使用当前输入量和储备量计算能得到的输出量
        // 这里调用的是考虑了0.3%手续费的计算公式
        amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
    }
}

getAmountsIn

  • 作用:计算为获取固定数量的目标代币,通过路径 path 需要提供多少输入代币。

  • 使用场景:当用户指定了目标输出代币数量(如 swapTokensForExactTokens)时,Router 会调用 getAmountsIn 来估算每一步所需的输入代币数量。

  • 计算方法

    • 初始化数组:

      • 创建一个长度为 path.length 的数组 amounts,用于存储每一步的输入数量。
    • 设置最终输出数量:

      • amounts[amounts.length - 1] = amountOut,表示最后一步的输出数量。
    • 逆向遍历路径:

      • 从路径的最后一个代币对开始,向前计算每一步的输入数量。

      • 对于每一对代币(例如 TokenB -> TokenA),调用 getReserves 获取流动性池的储备量。

      • 使用 getAmountIn 函数计算当前步骤的输入数量,公式为:

        amountIn=reserveIn×amountOut×1000reserveOut×997amountOut×997\text{amountIn} = \frac{\text{reserveIn} \times \text{amountOut} \times 1000}{\text{reserveOut} \times 997 - \text{amountOut} \times 997}
      • 将计算结果存入 amounts 数组。

    • 返回结果:

      • 返回 amounts 数组,表示每一步的输入数量。

代码实现如下

// 对任意数量的交易对执行链式 getAmountIn 计算
function getAmountsIn(
    address factory, // Uniswap 工厂合约地址
    uint amountOut,  // 目标输出代币数量
    address[] memory path // 兑换路径,例如 [TokenA, TokenB, TokenC]
) internal view returns (uint[] memory amounts) {
    // 检查路径长度是否至少为 2,确保至少有一个交易对
    require(path.length >= 2, 'UniswapV2Library: INVALID_PATH');

    // 初始化一个数组,用于存储每一步的输入代币数量
    amounts = new uint[](path.length);

    // 设置最终输出代币数量
    amounts[amounts.length - 1] = amountOut;

    // 从路径的最后一个代币对开始,向前遍历路径
    for (uint i = path.length - 1; i > 0; i--) {
        // 获取当前交易对的储备量(reserveIn 和 reserveOut)
        (uint reserveIn, uint reserveOut) = getReserves(factory, path[i - 1], path[i]);

        // 使用 getAmountIn 函数计算当前步骤的输入代币数量
        amounts[i - 1] = getAmountIn(amounts[i], reserveIn, reserveOut);
    }
}

Route._swap

Router 的 swap 是用户与 Uniswap 交互的入口,负责规划路径并调用 Pair 的 swap。它的主要任务是:

  • 根据用户输入的代币数量或期望的输出代币数量,计算跨池的兑换路径和输出结果。
  • 确保每一步的交易符合用户设置的滑点保护。
  • 依次调用多个交易对(Pair)的 swap 函数,最终把代币发送给目标地址。

通过上面几节代码,你应该知道了在 Route 中是通过 swapExactETHForTokens 或者 swapExactTokensForTokens 来计算路径,并调用 _swap 完成跨池交易的。下面我们来看一下具体的 _swap 代码,核心逻辑如下:

  1. 确定当前交易对
    • 每次循环确定当前的输入代币(input)、输出代币(output),以及对应的 Pair 合约地址。
  2. 计算输出方向
    • 根据输入代币的顺序,确定是 amount0Out 还是 amount1Out,确保输出代币的方向正确。
  3. 确定接收地址
    • 如果是路径中的中间交易对,输出代币将发送到下一个 Pair 合约。
    • 如果是最后一个交易对,输出代币直接发送到用户(_to)。
  4. 调用 Pair 的 swap 函数
    • 将当前交易对的输出代币转移到目标地址,同时执行恒定乘积公式的验证。

代码实现如下

/**
 * @dev 根据path路径和其amounts量进行交易对兑换
 * @param amounts 在每对交易对进行输入的token的数量,对应path
 * @param path 当没有两种token的交易对,需要进行多个兑换(tokenA->tokenB->ETH)
 * @param _to 接受兑换token的地址
 */
function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {
    // 遍历整个兑换路径,除了最后一个token(因为它是最终要获得的token)
    for (uint i; i < path.length - 1; i++) {
        // 获取当前交易对的输入和输出token
        // 例如:path = [USDC, WETH, DAI]
        // 第一轮:input = USDC, output = WETH
        // 第二轮:input = WETH, output = DAI
        (address input, address output) = (path[i], path[i + 1]);
        
        // Uniswap V2 的每个交易对(Pair)合约内部总是按照固定顺序存储两个代币(地址较小的为token0,较大的为token1)
        // 返回排序后的token0(较小地址)
        (address token0, ) = UniswapV2Library.sortTokens(input, output);
        
        // amounts数组包含了每步交易要获得的数量
        // amounts = [1000 USDC, 0.5 WETH, 800 DAI]
        uint amountOut = amounts[i + 1];
        
        // Uniswap pair合约要求我们指定token0和token1分别要输出多少
        // 我们需要知道输出的代币是 token0 还是 token1,才能正确设置 amount0Out 和 amount1Out
        // 如果我们的输入token是token0,那么我们要输出的是token1(amount1Out)
        // 反之如果输入token是token1,那么我们要输出的是token0(amount0Out)
        (uint amount0Out, uint amount1Out) = input == token0 
            ? (uint(0), amountOut)  // 输入是token0,输出token1
            : (amountOut, uint(0)); // 输入是token1,输出token0
            
        // 确定输出token应该发送到哪里:
        // 1. 如果这不是最后一次交换,发送到下一个交易对
        // 2. 如果这是最后一次交换,发送到用户指定的地址
        address to = i < path.length - 2 
            ? UniswapV2Library.pairFor(factory, output, path[i + 2]) // 下一个交易对的地址
            : _to;                                                    // 用户指定的最终接收地址
            
        // 调用交易对合约执行实际的交换
        IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap(
            amount0Out,
            amount1Out,
            to,
            new bytes(0)  // 没有额外数据
        );
    }
}

Pair.swap

Pair.swap 函数是 Uniswap V2 的核心功能,负责处理代币交换。其主要逻辑包括:

  1. 参数验证:确保输出代币数量合法且池子流动性充足。
  2. 代币转移:将输出代币转移到接收地址,并支持闪电贷功能。
  3. 输入代币计算:通过余额变化计算实际输入的代币数量。
  4. 手续费调整和 K 值验证:扣除手续费后,确保交易后池子仍满足恒定乘积公式。
  5. 更新储备量:更新池子的储备量和累积价格,确保后续交易的正确性。

核心逻辑如下

  1. 参数验证
  • 检查输出代币数量:
    • 确保 amount0Outamount1Out 至少有一个大于 0,否则交易无意义。
    • 如果两者都为 0,交易无效,直接回滚。
  • 检查储备量:
    • 确保输出的代币数量不超过池中的储备量(reserve0reserve1),否则池子流动性不足。
    • 如果输出数量超过储备量,交易无效,直接回滚。
  1. 转移输出代币
  • 代币转移:
    • 如果 amount0Out > 0,将 amount0Out 数量的代币0转移到 to 地址。
    • 如果 amount1Out > 0,将 amount1Out 数量的代币1转移到 to 地址。
  • 闪电贷支持:
    • 如果 data 不为空,调用 to 地址的 uniswapV2Call 回调函数,支持闪电贷(Flash Swap)功能。
    • 回调函数可以执行任意逻辑,但必须在回调中返回足够的代币以完成交易。
  1. 计算输入代币数量
  • 余额变化计算:
    • 通过比较当前代币余额和之前的储备量,计算实际输入的代币数量(amount0Inamount1In)。
    • 如果余额增加,表示有代币输入;如果余额减少,表示有代币输出。
  • 输入代币数量验证:
    • 确保至少有一个输入代币数量大于 0,否则交易无效。
  1. 手续费调整和 K 值验证
  • 手续费计算:
    • Uniswap V2 对每笔交易收取 0.3% 的手续费。
    • 输入代币数量需要扣除手续费后,再参与恒定乘积公式的验证。
  • 恒定乘积公式验证:
    • 计算新的储备量(balance0balance1),并确保满足恒定乘积公式 x∗y=kxy=k
    • 如果新的储备量不满足公式,交易无效,直接回滚。
  1. 更新储备量
  • 调用 _update 函数:
    • 更新池子的储备量(reserve0reserve1),确保后续交易的正确性。
    • 更新累积价格(price0CumulativeLastprice1CumulativeLast),用于外部价格预言机。
  • 触发事件:
    • 触发 Swap 事件,记录交易的详细信息,包括输入输出代币数量和接收地址。

代码实现如下

//对应的UniswapV2Factory合约地址
address public factory;
// 交易对中的两个token地址
address public token0;
address public token1;
// 两个token在交易对中的储备量
uint112 private reserve0;
uint112 private reserve1;

/**
 * @dev: 根据tokenA的数量在交易池中进行交换tokenB
 * @param {uint} amount0Out:to地址接受tokenA的数量
 * @param {uint} amount1Out:to地址接受tokenB的数量
 * @param {address} to:接受token交换的地址
 * @param {bytes} data:是否进行回调其他方法
 */
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
    // 确保至少有一个代币被交换
    require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
    
    // 获取当前交易对中两种代币的储备量
    // 例如:_reserve0 = 100 ETH, _reserve1 = 200000 USDT
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
    
    // 确保输出量不超过当前储备量
    // 例如:要换出2 ETH,但池子只有1 ETH,这种情况就会失败
    require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

    uint balance0;
    uint balance1;
    {
        // 作用域内声明token地址,避免堆栈过深
        address _token0 = token0; // 例如:ETH地址
        address _token1 = token1; // 例如:USDT地址
        
        // 防止直接转账给代币合约地址
        require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
        
        // 转移代币给接收地址
        // 例如:用户用1 ETH换USDT,此时amount0Out=0,amount1Out=1960
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);  // 转ETH
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);  // 转USDT
        
        // 如果有回调数据,执行回调(用于闪电贷等场景)
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        
        // 获取交易后合约的实际余额
        // 例如:用户投入1 ETH换USDT
        // balance0 = 101 ETH (原有100 + 用户新投入1)
        // balance1 = 198040 USDT (原有200000 - 用户换走1960)
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
    }

    // 计算实际投入的代币数量
    // 例如:用户用1 ETH换USDT的场景
    // amount0In = 101 - (100 - 0) = 1 ETH
    // amount1In = 198040 - (200000 - 1960) = 0 USDT
    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');
    
    {
        // 计算调整后的余额,包含0.3%的手续费
        // 例如:投入1 ETH
        // balance0Adjusted = 101 * 1000 - 1 * 3 = 100997
        // balance1Adjusted = 198040 * 1000 - 0 * 3 = 198040000
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
        
        // 验证K值(确保价格影响在允许范围内)
        // k = x * y = 100 * 200000 = 20000000
        // 新的k值必须大于或等于原来的k值
        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);
}

总结

  1. Uniswap V2 的 v2-periphery(Router 合约)主要负责用户交互,包括路径规划、代币转移和多跳交易。
  2. v2-core(Pair 合约)负责核心的代币兑换逻辑,基于恒定乘积公式进行价格计算和状态更新。
  3. 在多跳交易中,Router 会逐步调用多个流动性池的 swap 函数,确保交易沿路径正确执行。

Flash swap

Flash Swap 的核心思想是“借用后偿还”。用户可以从 Uniswap V2 的流动性池中借用任意数量的代币,只要在交易结束前偿还借用的代币或等值的其他代币。这个过程涉及用户合约与 Uniswap V2 合约之间的回调机制,整个过程是原子的,即要么全部成功,要么全部失败。

  1. 借用代币:用户可以选择从池中借用一种或多种代币。
  2. 执行操作:在借用代币后,用户可以在区块链上执行任意操作,例如套利、再投资等。
  3. 偿还代币:在交易的最后,用户必须偿还借用的代币,否则交易将被回滚。

交互过程

  1. 发起交易

    • 用户调用 Uniswap V2 Pair 合约的 swap 函数。
    • 指定要借用的代币数量(amount0amount1)以及接收代币的合约地址。
  2. 触发回调

    • 如果 data 参数非空,Uniswap V2 合约会调用接收合约的 uniswapV2Call 函数。
    • 这就是用户执行自定义逻辑的地方。
  3. 执行自定义逻辑

    • uniswapV2Call 中,用户可以进行套利、再投资等操作。
    • 用户需要确保在操作完成后偿还借用的代币或等值的其他代币。
  4. 偿还代币

    • 用户在 uniswapV2Call 中计算需要偿还的金额,包括借用的代币和手续费。
    • 将代币转回给 Uniswap V2 Pair 合约。
  5. 交易结束

    • 如果偿还成功,交易完成。
    • 如果偿还失败,整个交易回滚。

实现原理

Step 1: 发起交易

用户调用 swap 方法:

function executeFlashSwap(address pair, uint amount0, uint amount1) external {
    IUniswapV2Pair(pair).swap(amount0, amount1, address(this), bytes('not empty'));
}
  • 参数解释
    • amount0amount1:分别为借用的代币数量。
    • address(this):接收代币的合约地址。
    • bytes('not empty'):触发回调的标志。

Step 2: 触发回调

Uniswap V2 合约在 swap 方法中检查 data 参数是否非空。如果非空,则调用接收合约的 uniswapV2Call

function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external override {
    // 确保调用者是合法的 Uniswap V2 Pair 合约
    address pair = msg.sender;
    require(IUniswapV2Factory(UNISWAP_V2_ROUTER).getPair(IUniswapV2Pair(pair).token0(), IUniswapV2Pair(pair).token1()) == pair, "Invalid pair");

    // 继续执行自定义逻辑
}
  • 安全检查:验证调用者是否是合法的 Pair 合约,防止恶意调用。

Step 3: 执行自定义逻辑

uniswapV2Call 中执行套利或其他操作:

// 示例:假设套利操作
// 进行交易或其他操作

Step 4: 偿还代币

计算偿还金额并偿还:

uint amountToRepay = (amount0 > 0) ? amount0 : amount1;
uint fee = (amountToRepay * 3) / 997 + 1;
uint amountRequired = amountToRepay + fee;

IERC20 token = (amount0 > 0) ? IERC20(IUniswapV2Pair(pair).token0()) : IERC20(IUniswapV2Pair(pair).token1());
token.transfer(pair, amountRequired);
  • 费用计算:手续费为 0.3%,计算公式为 fee = (amountToRepay * 3) / 997 + 1
  • 偿还操作:将偿还金额转回给 Pair 合约。

image-20250119151056724

Step 5: 交易结束

  • 成功:如果代币正确偿还,交易完成。
  • 失败:如果未能偿还,交易回滚,所有状态恢复到初始状态。

示例代码

该合约实现了一个简单的 Uniswap V2 Flash Swap 功能,允许用户借用代币并在同一交易中偿还。合约通过实现 uniswapV2Call 回调函数,与 Uniswap V2 合约进行交互。以 UNISWAP_V2_PAIR_DAI_WETH 交易对为例,大致过程如下

image-20250119153553104

代码结构与核心逻辑

  • 合约名称: UniswapV2FlashSwap
  • 依赖接口:
    • IUniswapV2Pair:用于与 Uniswap V2 交易对交互。
    • IERC20:用于处理 ERC20 代币的转账。
  • 核心功能:
    • flashSwap: 发起 FlashSwap 交易。
    • uniswapV2Call: Uniswap V2 回调函数,处理借出资产后的逻辑。

代码实现

contract UniswapV2FlashSwap {
    IUniswapV2Pair private immutable pair;
    address private immutable token0;
    address private immutable token1;

    constructor(address _pair) {
        pair = IUniswapV2Pair(_pair);
        token0 = pair.token0();
        token1 = pair.token1();
    }

    function flashSwap(address token, uint256 amount) external {
        if (token != token0 && token != token1) {
            revert InvalidToken();
        }
        
        // 1. Determine amount0Out and amount1Out
        (uint256 amount0Out, uint256 amount1Out) =
            token == token0 ? (amount, uint256(0)) : (uint256(0), amount);

        // 2. Encode token and msg.sender as bytes
        bytes memory data = abi.encode(token, msg.sender);

        // 3. Call pair.swap
        pair.swap({
            amount0Out: amount0Out,
            amount1Out: amount1Out,
            to: address(this),
            data: data
        });
    }

    // Uniswap V2 callback
    function uniswapV2Call(
        address sender,
        uint256 amount0,
        uint256 amount1,
        bytes calldata data
    ) external {
        // 1. Require msg.sender is pair contract
        // 2. Require sender is this contract
        // Alice -> FlashSwap ---- to = FlashSwap ----> UniswapV2Pair
        //                    <-- sender = FlashSwap --
        // Eve ------------ to = FlashSwap -----------> UniswapV2Pair
        //          FlashSwap <-- sender = Eve --------
        if (msg.sender != address(pair)) {
            revert NotPair();
        }
        // 2. Check sender is this contract
        if (sender != address(this)) {
            revert NotSender();
        }
        // 3. Decode token and caller from data
        (address token, address caller) = abi.decode(data, (address, address));
        // 4. Determine amount borrowed (only one of them is > 0)
        uint256 amount = token == token0 ? amount0 : amount1;

        // 5. Calculate flash swap fee and amount to repay
        // fee = borrowed amount * 3 / 997 + 1 to round up
        uint256 fee = ((amount * 3) / 997) + 1;
        uint256 amountToRepay = amount + fee;

        // 6. Get flash swap fee from caller
        IERC20(token).transferFrom(caller, address(this), fee);
        // 7. Repay Uniswap V2 pair
        IERC20(token).transfer(address(pair), amountToRepay);
    }
}

代码解释

第一步:发起 FlashSwap

  1. 用户调用 flashSwap:

    • 用户指定要借出的代币(token)和数量(amount)。
    • 合约检查代币是否为交易对中的 token0token1,否则抛出错误。
  2. 确定借出金额:

    • 根据用户指定的代币类型,确定 amount0Outamount1Out
      • 如果借出的是 token0,则 amount0Outamountamount1Out0
      • 如果借出的是 token1,则 amount1Outamountamount0Out0
  3. 准备回调数据:

    • 将借出的代币地址和调用者地址(msg.sender)编码为 bytes 数据,用于后续回调。
  4. 调用交易对的 swap 函数:

    • 调用 pair.swap,触发 Uniswap V2 交易对的资产借出操作。
    • 传入参数:
      • amount0Outamount1Out:借出的金额。
      • to:当前合约地址(address(this)),用于接收借出的资产。
      • data:编码后的回调数据。

第二步:Uniswap V2 回调

  1. 触发回调函数 uniswapV2Call:

    • Uniswap V2 交易对在完成资产借出后,会自动调用当前合约的 uniswapV2Call 函数。
  2. 安全性检查:

    • 检查调用者是否为交易对合约:
      • 确保回调是由合法的 Uniswap V2 交易对触发的,防止恶意调用。
    • 检查发送者是否为当前合约:
      • 确保 FlashSwap 的发起者是当前合约,防止其他地址滥用回调函数。
  3. 解码回调数据:

    • data 中解码出借出的代币地址(token)和调用者地址(caller)。
  4. 确定借出金额:

    • 根据借出的代币类型,确定实际借出的金额(amount):
      • 如果借出的是 token0,则 amountamount0
      • 如果借出的是 token1,则 amountamount1
  5. 计算手续费和归还金额:

    • 手续费计算:
      • Uniswap V2 的 FlashSwap 手续费为借出金额的 0.3%,计算公式为 (amount * 3) / 997 + 1
      • 其中 +1 是为了确保手续费向上取整,避免因小数部分被截断而导致手续费不足。
    • 归还金额:
      • 归还金额包括借出的资产和手续费,即 amount + fee
  6. 从调用者处收取手续费:

    • 使用 transferFrom 从调用者(caller)的账户中转移手续费(fee)到当前合约。
    • 这一步确保手续费被正确支付,且流动性提供者能够获得奖励。
  7. 归还借出资产和手续费:

    • 使用 transfer 将借出的资产和手续费(amountToRepay)归还给 Uniswap V2 交易对。
    • 这一步完成 FlashSwap 的闭环,确保交易对的资产不被无偿占用。

作者:加密鲸拓

版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!