02.Uniswap V2 Swap 操作
这份学习笔记将Uniswap V2的swap操作进行了深入剖析,涵盖了其核心机制、数学原理和代码实现,以及手续费的计算方式。
核心摘要 (Key Takeaways)
- Uniswap V2 架构与路由灵活性:V2引入了任何ERC20代币之间直接交换的能力,不再强制通过ETH作为中介。用户通过
UniswapV2Router02合约进行交互,该合约负责寻找并协调UniswapV2Pair合约完成交易,支持单跳和多跳(多级路由)交换。 - 两种核心交换类型:V2提供了两种主要的
swap函数:swapExactTokensForTokens:用户指定精确的输入代币数量,系统计算并返回尽可能多的输出代币。swapTokensForExactTokens:用户指定精确的输出代币数量,系统计算所需的最小输入代币数量。
- 0.3%固定手续费机制:每次
swap操作都会对输入代币收取0.3%(千分之三)的手续费。这意味着实际用于AMM计算的输入量是用户提供量的99.7%。多跳交易会累积手续费。 - 数学原理与代码实现:
UniswapV2Library中的getAmountOut和getAmountIn函数是核心计算逻辑,它们基于恒定乘积做市商(AMM)公式,并融入了0.3%的手续费计算,确保了交易的准确性。 - 滑点与MEV攻击:用户可以通过设置
amountOutMin(最小输出量)来控制滑点。然而,这种机制也可能被MEV(矿工可提取价值)套利者利用,通过三明治攻击(Front-running + Back-running)来捕获用户设定的最小输出与实际可能输出之间的差价。
1. Uniswap V2 核心架构与合约
Uniswap V2 的代码主要存在于两个仓库:
v2-periphery:https://github.com/Uniswap/v2-peripheryv2-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: (交易完成)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: (交易完成)
流程说明:
- 用户调用路由合约:用户首先与
UniswapV2Router02.sol合约进行交互,调用其swap方法。 - 转账至交易对:路由合约(作为用户预先授权的
spender)会调用transferFrom函数,将用户待交换的代币(例如 DAI)从用户地址转入对应的UniswapV2Pair合约(DAI/USDT 交易对)。 - 交易对内部交换:
Pair合约收到代币后,在其内部根据恒定乘积做市商(AMM)公式执行交换逻辑,并扣除手续费。 - 转账回用户:
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。
swapExactETHForTokens 和 swapTokensForExactETH 是为了方便 ETH 与 ERC20 代币交换而设计的特殊版本,因为 ETH 不是 ERC20 代币,需要特殊处理(例如,在内部进行 WETH 的封装/解封装)。
4. 多跳(Multi-Hop)交换机制
Uniswap V2 的一个重要特性是支持任意 ERC20 代币之间的交换,即使它们之间没有直接的交易对。这通过**多跳路由(Multi-Hop Path)**实现。
- 原理:用户可以通过中间代币进行多次交换。例如,如果
DAI/MKR交易对不存在,但存在DAI/USDT和USDT/MKR交易对,用户可以先将 DAI 换成 USDT,再将 USDT 换成 MKR。 path参数:路由合约的swap函数接受一个path参数,这是一个代币地址数组,定义了交换的路径。- 例子:
path = [DAI_Address, USDT_Address, MKR_Address]表示DAI -> USDT -> MKR。
- 例子:
- 路由合约内部逻辑:
UniswapV2Router02合约内部会有一个for循环,遍历path中的每一个交易对,依次执行transferFrom、swap、transfer操作,直到完成整个路径的交换。 - 手续费累积:每一次独立的
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: (交易完成)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: (交易完成)
流程说明:
- 用户调用路由合约:用户调用
UniswapV2Router02.sol的swap方法,并传入一个包含所有中间代币的path数组。 - 第一跳:DAI -> USDT:
- 路由合约(作为用户预先授权的
spender)会调用transferFrom函数,将初始代币(DAI)从用户地址转入第一个交易对(DAI/USDTPair)。 DAI/USDTPair 内部执行swap,将 DAI 换成 USDT,并扣除第一次手续费。- 路由合约将生成的 USDT 从
DAI/USDTPair 转入下一个交易对(USDT/MKRPair)。
- 路由合约(作为用户预先授权的
- 第二跳:USDT -> MKR:
USDT/MKRPair 收到 USDT 后,内部执行swap,将 USDT 换成 MKR,并扣除第二次手续费。- 路由合约将最终生成的 MKR 从
USDT/MKRPair 转回给用户。
这个过程可以推广到任意多跳,每次跳跃都会在相应的交易对中完成一次独立的 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 的潜在利润空间)。
- 三明治攻击:
- Front-run:MEV 机器人先于用户交易,以略微改变价格的方式进行一笔小额交易,将池子价格推向对用户不利的方向。
- User’s Transaction:用户的交易执行,由于价格被推高,用户只能获得接近
amountOutMin的数量(例如 1950 USDT)。 - Back-run:MEV 机器人在用户交易之后,立即执行另一笔交易,将池子价格恢复到接近原始状态,从而捕获之前推高价格所产生的利润。
- MEV 科学家通过这种方式,将用户本可以获得的额外收益(例如上述的 40 USDT)收入囊中。MEV 的实现与用户设定的这些参数密切相关。
- 例子:用户想用 1 WETH 换 USDT,市场价 2000 USDT,用户设置
6. Uniswap V2 手续费机制与数学推导
Uniswap V2 每次swap操作都会对输入代币收取 0.3% (0.003) 的固定手续费。这意味着实际用于 AMM 计算的输入量是用户提供量的 (1 - 0.003) 倍,即 0.997 倍。
6.1 恒定乘积做市商 (AMM) 公式
基础公式: 其中:
- :交易前池中两种代币的储备量。
- :常数乘积。
- :用户输入的代币数量。
- :用户获得的代币数量。
考虑手续费后,实际进入池子的输入量为 ,其中 。 因此,AMM 恒定乘积公式变为:
6.2 getAmountOut (已知输入 ,求输出 )
这个函数用于 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;
}推导过程:
- 从考虑手续费的 AMM 公式开始:
- 展开并整理,目标是解出 :
- 代入手续费率 :
- 为了避免浮点数计算,通常将分子分母同乘以 1000:
- 其中, 对应
reserveIn, 对应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; // DY6.3 getAmountIn (已知输出 ,求输入 )
这个函数用于 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);
}推导过程:
- 从考虑手续费的 AMM 公式开始:
- 展开并整理,目标是解出 :
- 代入手续费率 :
- 为了避免浮点数计算,通常将分子分母同乘以 1000:
- 其中, 对应
reserveIn, 对应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 能够高效、透明地进行代币交换的核心,也是理解其机制的关键。