参考资料:
概念详解
Uniswap 是一个去中心化的交易协议,基于以太坊区块链,允许用户在无需中介的情况下进行代币交换(Token Swap)。它是自动化做市商(AMM, Automated Market Maker)的一个典型实现,其核心机制是 恒定乘积公式,即:,其中:
- 和 分别表示池中两种代币的数量;
- 是一个固定值,表示池子的恒定流动性。
Uniswap的特点:
- 无需订单簿:Uniswap 不依赖传统的订单簿交易模式,用户无需与另一方直接匹配,而是与流动性池交互。
- 去中心化:Uniswap 的合约是开源且去中心化的,任何人都可以自由添加流动性或创建新的交易对。
核心机制
-
流动性(L, Liquidity):
- 流动性越大,交易曲线越平缓。这意味着交易者能够以更好的价格进行代币交换,减少滑点(Slippage)。
- 流动性提供者通过存入等价值的两种代币,为池子注入流动性,并从每笔交易中收取交易费用作为奖励。
-
恒定乘积公式:
- 在公式 中,无论池中代币的比例如何变化,乘积 的值始终保持不变。这意味着随着一种代币的增加,另一种代币的数量必须减少以维持平衡。这种机制确保了另一种代币的数量会根据交易动态调整。
- 例如,当一个用户向池中加入一种代币(例如)时,另一种代币(例如)的数量会减少,从而保持公式的平衡。
案例分析
假设池中初始的代币分布为 ,,恒定乘积为 。
用户加入 个代币 (),此时池中的 总量变为 ,根据公式:
所以代币 减少 (),意味着用户用 200 个 代币换得了 100 个 代币。
假设初始池中的代币分布为 ,,恒定乘积为 。
用户再加入 个代币 (),此时池中的 总量变为 ,根据公式:
所以代币 减少约 (),意味着此时用户用 200 个 代币换得了约 133 个 代币。
when L is a large number, traders will get a better deal for the same amount of token that they put in
结论:随着流动性()的增加,交易对价格的变化曲线趋于平滑,即 当池中资金量较大时,同样的交易量会导致更小的价格波动,从而让交易者获得更好的交易价格。
代码仓库
Uniswap V2 的架构分为两部分:
-
v2-core
仓库:包含核心的智能合约逻辑(如 Pair 合约),可以直接交互,但不建议直接交互; -
v2-periphery
仓库:包含辅助合约(如 Router 合约),负责和用户交互,会帮我们调用v2-core。
核心组件
Uniswap V2 主要由三个核心组件构成:
-
Factory 合约:
- 用于管理所有交易对(Token Pair)的工厂合约。
- 功能:创建新的交易对,并记录每个交易对的地址。
- 作用:为每个交易对分配一个唯一的合约实例。
-
Router 合约:
- 用户通过Router与 Uniswap 交互,用于实现代币交换、添加流动性、移除流动性等核心功能。
- 功能:路由器合约将用户请求引导至对应的交易对合约中。
-
Pair 合约:
- 每个交易对都有独立的合约,用于存储流动性和管理代币交换。
- 功能:负责维护池中代币的比例和恒定乘积关系,处理用户的交易和流动性操作。
Swap Fee
在 Uniswap V2 中,每次代币交换都会收取一部分交易费用(Swap Fee),这部分费用是对流动性提供者的奖励。以下是具体的计算过程和公式推导:
没有 Swap Fee 的情况
对于恒定乘积公式 ,用户提供 数量的代币 ,想要获得 数量的代币 。在不考虑交易费的情况下:
- 初始状态(交易前):
- 交易后状态:
根据公式恒定乘积的要求:
推导得:
即用户可以获得的代币数量 由上式计算。
考虑 Swap Fee 的情况
假设交易费率为 ,则:
- 用户实际提供的有效输入代币数量为 (扣除了 作为手续费)。
交易后的恒定乘积公式变为:
整理后,用户实际获得的代币数量 为:
举个具体的例子,假设:
- 初始池中有 DAI 和 ETH;
- 用户提供 DAI;
- Swap Fee 费率 (即 0.3%)。
代入公式计算:
用户最终可以获得约 ETH。
Swap 详解
Uniswap 的交易(Swap)分为两个主要模块:
v2-core
(核心逻辑):- 负责处理恒定乘积的自动做市商(AMM)机制,维护流动性池状态。
- 包含主要合约:
UniswapV2Pair.sol
。 - 核心任务:
- 确定代币的输入和输出数量。
- 维护恒定乘积公式 x⋅y=kx⋅y=k。
- 扣除手续费(默认 0.3%)。
- 更新流动性池储备。
v2-periphery
(外围逻辑):- 提供用户友好的接口(Router 合约),实现复杂的多跳路径规划。
- 包含主要合约:
UniswapV2Router02.sol
。 - 核心任务:
- 路径规划(例如多跳交易
A -> B -> C
)。 - 与
v2-core
的Pair
合约交互,逐步调用swap()
完成代币兑换。 - 管理 ETH 和 WETH 的交互(例如将 ETH 转换为 WETH)。
- 路径规划(例如多跳交易
过程概述
Swap 的场景大致分为两类
- 单次跳跃的直接 Swap
- 用户直接将一种代币换成目标代币。
- 示例:
- WETH -> DAI(直接通过
WETH/DAI
流动性池完成)。
- WETH -> DAI(直接通过
- 函数调用:
- 用户调用 Router 的
swapExactTokensForTokens
或swapExactETHForTokens
。 - Router 调用单个流动性池的
Pair.swap()
完成交易。
- 用户调用 Router 的
- 多跳路径的 Swap
- 用户通过多个流动性池进行兑换。
- 示例:
- WETH -> DAI -> MKR(通过
WETH/DAI
和DAI/MKR
两个流动性池)。
- WETH -> DAI -> MKR(通过
- 函数调用:
- 用户调用 Router 的
swapExactTokensForTokens
。 - Router 会按路径依次调用多个流动性池的
Pair.swap()
,逐步完成交易。
- 用户调用 Router 的
Swap 的逻辑可以分为以下几个关键阶段:
- 用户调用 Router 合约发起交易
- 用户调用 Router 的接口(如
swapExactETHForTokens
或swapExactTokensForTokens
)。 - Router 负责路径规划,计算代币数量,并与 Pair 合约交互。
- 用户调用 Router 的接口(如
- 路径规划与数量计算
- Router 调用
getAmountsOut
计算每一步交易的输出数量,确定最终交易路径和滑点保护。 - 如果是多跳交易,逐步规划每一步的输入和输出。
- Router 调用
- 代币转移与准备
- 如果是 ERC20 代币交易,Router 会将用户的代币直接转移到 Pair 合约(通常通过
safeTransfer
)。 - 如果是 ETH 交易,Router 会先通过 WETH 合约的
deposit
将 ETH 包装成 WETH。
- 如果是 ERC20 代币交易,Router 会将用户的代币直接转移到 Pair 合约(通常通过
- 调用 Pair 的
swap
函数完成交易- Router 按路径逐步调用 Pair 合约的
swap
函数。 - Pair 根据恒定乘积公式计算输出代币数量,扣除手续费,更新储备,并将目标代币发送至下一个接收地址。
- Router 按路径逐步调用 Pair 合约的
- 结果交付
- 如果是多跳交易,最后一步输出的代币会发送至用户指定地址。
核心函数
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】:
调用函数:Router.swapExactETHForTokens(uint amountOutMin, address[] calldata path, address to, uint deadline)
-
输入参数:
amountOutMin
:用户期望收到的最小 DAI 数量(滑点保护);path
:交换路径,例:[WETH, DAI]
;to
:交易完成后,接收 DAI 的地址;deadline
:交易必须在此时间前完成,否则失败。
-
步骤拆解:
-
计算路径上的输出 (
getAmountsOut
):- 根据用户输入的
amountIn
和兑换路径(path
),计算每个池子中会输出多少代币。 - 依赖公式:
amountOut = reserveOut * amountIn / (reserveIn + amountIn)
。 - 如果路径是
[TokenA, TokenB, TokenC]
,会计算:TokenA -> TokenB
的输出数量;- 然后
TokenB -> TokenC
的输出数量。
- 根据用户输入的
-
收取 ETH 并包装为 WETH:
-
Router 合约的
swapExactETHForTokens
函数调用IWETH.deposit()
,将用户发送的 ETH 转换为 WETH,存储在 Router 合约中。
-
转移 WETH 至 Pair 合约:
- Router 合约调用
IERC20(WETH).transfer()
,将用户提供的 WETH 发送至对应的流动性池(DAI/WETH
池)。
- Router 合约调用
-
调用 Pair 合约的 Swap 函数:
- Router 合约调用
Pair.swap()
,通过path
指定的路径,从 WETH 换取 DAI。 - 核心逻辑(
Pair.swap()
):swap
函数会检查x * y = k
恒定乘积公式,计算输入的 WETH 数量对应的输出 DAI 数量(扣除手续费)。- 更新池子的代币余额,并将用户的目标代币(DAI)发送至
to
地址。
- Router 合约调用
-
将 DAI 转移至用户:
- Router 合约完成最后的转账操作,将从 Pair 池中获取的 DAI 转移到用户指定的地址。
-
核心调用路径:
-
用户调用
Router.swapExactETHForTokens
:- Router 调用
IWETH.deposit
将 ETH 转为 WETH。 - Router 将 WETH 转移至 Pair。
- Router 调用
Pair.swap
执行代币兑换。 - 最后将 DAI 发送到用户地址。
- Router 调用
-
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)完成交易。以下是每个步骤的详细执行逻辑:
调用函数:Router.swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline)
-
输入参数:
amountIn
:用户提供的 WETH 数量;amountOutMin
:用户期望的最小 MKR 数量;path
:交换路径,例:[WETH, DAI, MKR]
;to
:最终接收 MKR 的地址;deadline
:交易必须在此时间前完成。
-
步骤拆解:
- 同上,先计算路径上的输出 (
getAmountsOut
),不再赘述: - 转移 WETH 至 Pair 合约:
- Router 合约首先调用
IERC20(WETH).safeTransferFrom()
,将用户提供的 WETH 转移到DAI/WETH
流动性池中。 - 该函数会验证用户是否批准了 Router 使用指定数量的 WETH。
- Router 合约首先调用
- 调用 Pair 合约的 Swap 函数:
- Router 合约调用
Pair.swap()
,通过path
指定的路径完成多跳交易。
- Router 合约调用
- 第一跳交易:WETH -> DAI:
- Router 调用
Pair.swap()
执行第一步兑换。 - Pair.swap 核心操作:
- 调用
getReserves
获取DAI/WETH
池的当前代币储备; - 根据恒定乘积公式计算用户提供的 WETH 可以换取多少 DAI;
- 扣除交易手续费(0.3%);
- 将换得的 DAI 发送至
DAI/MKR
池。
- 调用
- Router 调用
- 第二跳交易:DAI -> MKR:
- 第一跳交易完成后,Router 将获得的 DAI 发送至
DAI/MKR
流动性池。 - 再次调用
Pair.swap()
,根据getReserves
和恒定乘积公式计算可以兑换的 MKR 数量; - 将最终获得的 MKR 发送至用户指定地址。
- 第一跳交易完成后,Router 将获得的 DAI 发送至
- 同上,先计算路径上的输出 (
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); }
核心调用路径:
-
用户调用
Router.swapExactTokensForTokens
:- Router 将用户提供的 WETH 转移至
DAI/WETH
池。 - Router 调用
Pair.swap
完成第一跳(WETH -> DAI)。 - Router 将第一跳的输出(DAI)转移至
DAI/MKR
池。 - Router 调用
Pair.swap
完成第二跳(DAI -> MKR)。 - 最终将 MKR 转移至用户地址。
- Router 将用户提供的 WETH 转移至
-
Pair 合约的
swap
核心逻辑:- 每一跳都遵循
x * y = k
的恒定乘积公式,计算输出代币; - 扣除 0.3% 的 Swap Fee;
- 更新储备量;
- 转移输出代币。
- 每一跳都遵循
代码详解
getAmountsOut
-
作用:计算固定输入代币数量通过路径
path
转换后,最终可以得到多少目标代币。 -
使用场景:当用户指定了输入代币数量(如
swapExactTokensForTokens
)时,Router 会调用getAmountsOut
来估算每一步的输出代币数量,并确保最终输出满足用户要求。 -
实现逻辑
-
初始化数组:
- 创建一个长度为
path.length
的数组amounts
,用于存储每一步的输出数量。
- 创建一个长度为
-
设置初始输入数量:
amounts[0] = amountIn
,表示第一步的输入数量。
-
遍历路径:
-
对于路径中的每一对代币(例如
TokenA -> TokenB
),调用getReserves
获取流动性池的储备量。 -
使用
getAmountOut
函数计算当前步骤的输出数量,公式为:
-
- 将计算结果存入
amounts
数组。
-
-
返回结果:
- 返回
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
函数计算当前步骤的输入数量,公式为: -
将计算结果存入
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
代码,核心逻辑如下:
- 确定当前交易对:
- 每次循环确定当前的输入代币(
input
)、输出代币(output
),以及对应的 Pair 合约地址。
- 每次循环确定当前的输入代币(
- 计算输出方向:
- 根据输入代币的顺序,确定是
amount0Out
还是amount1Out
,确保输出代币的方向正确。
- 根据输入代币的顺序,确定是
- 确定接收地址:
- 如果是路径中的中间交易对,输出代币将发送到下一个 Pair 合约。
- 如果是最后一个交易对,输出代币直接发送到用户(
_to
)。
- 调用 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 的核心功能,负责处理代币交换。其主要逻辑包括:
- 参数验证:确保输出代币数量合法且池子流动性充足。
- 代币转移:将输出代币转移到接收地址,并支持闪电贷功能。
- 输入代币计算:通过余额变化计算实际输入的代币数量。
- 手续费调整和 K 值验证:扣除手续费后,确保交易后池子仍满足恒定乘积公式。
- 更新储备量:更新池子的储备量和累积价格,确保后续交易的正确性。
核心逻辑如下
- 参数验证
- 检查输出代币数量:
- 确保
amount0Out
和amount1Out
至少有一个大于 0,否则交易无意义。 - 如果两者都为 0,交易无效,直接回滚。
- 确保
- 检查储备量:
- 确保输出的代币数量不超过池中的储备量(
reserve0
和reserve1
),否则池子流动性不足。 - 如果输出数量超过储备量,交易无效,直接回滚。
- 确保输出的代币数量不超过池中的储备量(
- 转移输出代币
- 代币转移:
- 如果
amount0Out > 0
,将amount0Out
数量的代币0转移到to
地址。 - 如果
amount1Out > 0
,将amount1Out
数量的代币1转移到to
地址。
- 如果
- 闪电贷支持:
- 如果
data
不为空,调用to
地址的uniswapV2Call
回调函数,支持闪电贷(Flash Swap)功能。 - 回调函数可以执行任意逻辑,但必须在回调中返回足够的代币以完成交易。
- 如果
- 计算输入代币数量
- 余额变化计算:
- 通过比较当前代币余额和之前的储备量,计算实际输入的代币数量(
amount0In
和amount1In
)。 - 如果余额增加,表示有代币输入;如果余额减少,表示有代币输出。
- 通过比较当前代币余额和之前的储备量,计算实际输入的代币数量(
- 输入代币数量验证:
- 确保至少有一个输入代币数量大于 0,否则交易无效。
- 手续费调整和 K 值验证
- 手续费计算:
- Uniswap V2 对每笔交易收取 0.3% 的手续费。
- 输入代币数量需要扣除手续费后,再参与恒定乘积公式的验证。
- 恒定乘积公式验证:
- 计算新的储备量(
balance0
和balance1
),并确保满足恒定乘积公式 x∗y=kx∗y=k。 - 如果新的储备量不满足公式,交易无效,直接回滚。
- 计算新的储备量(
- 更新储备量
- 调用
_update
函数:- 更新池子的储备量(
reserve0
和reserve1
),确保后续交易的正确性。 - 更新累积价格(
price0CumulativeLast
和price1CumulativeLast
),用于外部价格预言机。
- 更新池子的储备量(
- 触发事件:
- 触发
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); }
总结
- Uniswap V2 的
v2-periphery
(Router 合约)主要负责用户交互,包括路径规划、代币转移和多跳交易。 v2-core
(Pair 合约)负责核心的代币兑换逻辑,基于恒定乘积公式进行价格计算和状态更新。- 在多跳交易中,Router 会逐步调用多个流动性池的
swap
函数,确保交易沿路径正确执行。
Flash swap
Flash Swap 的核心思想是“借用后偿还”。用户可以从 Uniswap V2 的流动性池中借用任意数量的代币,只要在交易结束前偿还借用的代币或等值的其他代币。这个过程涉及用户合约与 Uniswap V2 合约之间的回调机制,整个过程是原子的,即要么全部成功,要么全部失败。
- 借用代币:用户可以选择从池中借用一种或多种代币。
- 执行操作:在借用代币后,用户可以在区块链上执行任意操作,例如套利、再投资等。
- 偿还代币:在交易的最后,用户必须偿还借用的代币,否则交易将被回滚。
交互过程
-
发起交易:
- 用户调用 Uniswap V2 Pair 合约的
swap
函数。 - 指定要借用的代币数量(
amount0
和amount1
)以及接收代币的合约地址。
- 用户调用 Uniswap V2 Pair 合约的
-
触发回调:
- 如果
data
参数非空,Uniswap V2 合约会调用接收合约的uniswapV2Call
函数。 - 这就是用户执行自定义逻辑的地方。
- 如果
-
执行自定义逻辑:
- 在
uniswapV2Call
中,用户可以进行套利、再投资等操作。 - 用户需要确保在操作完成后偿还借用的代币或等值的其他代币。
- 在
-
偿还代币:
- 用户在
uniswapV2Call
中计算需要偿还的金额,包括借用的代币和手续费。 - 将代币转回给 Uniswap V2 Pair 合约。
- 用户在
-
交易结束:
- 如果偿还成功,交易完成。
- 如果偿还失败,整个交易回滚。
实现原理
Step 1: 发起交易
用户调用 swap
方法:
function executeFlashSwap(address pair, uint amount0, uint amount1) external { IUniswapV2Pair(pair).swap(amount0, amount1, address(this), bytes('not empty')); }
- 参数解释:
amount0
和amount1
:分别为借用的代币数量。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 合约。
Step 5: 交易结束
- 成功:如果代币正确偿还,交易完成。
- 失败:如果未能偿还,交易回滚,所有状态恢复到初始状态。
示例代码
该合约实现了一个简单的 Uniswap V2 Flash Swap 功能,允许用户借用代币并在同一交易中偿还。合约通过实现 uniswapV2Call
回调函数,与 Uniswap V2 合约进行交互。以 UNISWAP_V2_PAIR_DAI_WETH
交易对为例,大致过程如下
代码结构与核心逻辑
- 合约名称:
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
-
用户调用
flashSwap
:- 用户指定要借出的代币(
token
)和数量(amount
)。 - 合约检查代币是否为交易对中的
token0
或token1
,否则抛出错误。
- 用户指定要借出的代币(
-
确定借出金额:
- 根据用户指定的代币类型,确定
amount0Out
和amount1Out
:- 如果借出的是
token0
,则amount0Out
为amount
,amount1Out
为0
。 - 如果借出的是
token1
,则amount1Out
为amount
,amount0Out
为0
。
- 如果借出的是
- 根据用户指定的代币类型,确定
-
准备回调数据:
- 将借出的代币地址和调用者地址(
msg.sender
)编码为bytes
数据,用于后续回调。
- 将借出的代币地址和调用者地址(
-
调用交易对的
swap
函数:- 调用
pair.swap
,触发 Uniswap V2 交易对的资产借出操作。 - 传入参数:
amount0Out
和amount1Out
:借出的金额。to
:当前合约地址(address(this)
),用于接收借出的资产。data
:编码后的回调数据。
- 调用
第二步:Uniswap V2 回调
-
触发回调函数
uniswapV2Call
:- Uniswap V2 交易对在完成资产借出后,会自动调用当前合约的
uniswapV2Call
函数。
- Uniswap V2 交易对在完成资产借出后,会自动调用当前合约的
-
安全性检查:
- 检查调用者是否为交易对合约:
- 确保回调是由合法的 Uniswap V2 交易对触发的,防止恶意调用。
- 检查发送者是否为当前合约:
- 确保 FlashSwap 的发起者是当前合约,防止其他地址滥用回调函数。
- 检查调用者是否为交易对合约:
-
解码回调数据:
- 从
data
中解码出借出的代币地址(token
)和调用者地址(caller
)。
- 从
-
确定借出金额:
- 根据借出的代币类型,确定实际借出的金额(
amount
):- 如果借出的是
token0
,则amount
为amount0
。 - 如果借出的是
token1
,则amount
为amount1
。
- 如果借出的是
- 根据借出的代币类型,确定实际借出的金额(
-
计算手续费和归还金额:
- 手续费计算:
- Uniswap V2 的 FlashSwap 手续费为借出金额的 0.3%,计算公式为
(amount * 3) / 997 + 1
。 - 其中
+1
是为了确保手续费向上取整,避免因小数部分被截断而导致手续费不足。
- Uniswap V2 的 FlashSwap 手续费为借出金额的 0.3%,计算公式为
- 归还金额:
- 归还金额包括借出的资产和手续费,即
amount + fee
。
- 归还金额包括借出的资产和手续费,即
- 手续费计算:
-
从调用者处收取手续费:
- 使用
transferFrom
从调用者(caller
)的账户中转移手续费(fee
)到当前合约。 - 这一步确保手续费被正确支付,且流动性提供者能够获得奖励。
- 使用
-
归还借出资产和手续费:
- 使用
transfer
将借出的资产和手续费(amountToRepay
)归还给 Uniswap V2 交易对。 - 这一步完成 FlashSwap 的闭环,确保交易对的资产不被无偿占用。
- 使用
作者:加密鲸拓
版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!