Uniswap V2 - 交易机制

参考资料:

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 的输出数量。 1.收取 ETH 并包装为 WETH

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

2.转移 WETH 至 Pair 合约

 - Router 合约调用 `IERC20(WETH).transfer()`,将用户提供的 WETH 发送至对应的流动性池(`DAI/WETH` 池)。

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

$ \text{amountOut} = \frac{\text{amountIn} \times 997 \times \text{reserveOut}}{\text{reserveIn} \times 1000 + \text{amountIn} \times 997}

$

  • 将计算结果存入 amounts 数组。

4.返回结果:

  • 返回 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` 函数计算当前步骤的输入数量,公式为:
  

$ \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),否则池子流动性不足。
  • 如果输出数量超过储备量,交易无效,直接回滚。

2.转移输出代币

-代币转移:

  • 如果 amount0Out > 0,将 amount0Out 数量的代币0转移到 to 地址。
  • 如果 amount1Out > 0,将 amount1Out 数量的代币1转移到 to 地址。 -闪电贷支持:
  • 如果 data 不为空,调用 to 地址的 uniswapV2Call 回调函数,支持闪电贷(Flash Swap)功能。
  • 回调函数可以执行任意逻辑,但必须在回调中返回足够的代币以完成交易。

3.计算输入代币数量

-余额变化计算:

  • 通过比较当前代币余额和之前的储备量,计算实际输入的代币数量(amount0Inamount1In)。
  • 如果余额增加,表示有代币输入;如果余额减少,表示有代币输出。 -输入代币数量验证:
  • 确保至少有一个输入代币数量大于 0,否则交易无效。

4.手续费调整和 K 值验证

-手续费计算:

  • Uniswap V2 对每笔交易收取 0.3% 的手续费。
  • 输入代币数量需要扣除手续费后,再参与恒定乘积公式的验证。 -恒定乘积公式验证:
  • 计算新的储备量(balance0balance1),并确保满足恒定乘积公式 x∗y=kxy=k
  • 如果新的储备量不满足公式,交易无效,直接回滚。

5.更新储备量

-调用 _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 的闭环,确保交易对的资产不被无偿占用。

作者:加密鲸拓

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