Uniswap V2 学习笔记2. 交易算法

2022-05-25 15:59:45 浏览数 (2)

本文作者:tony.ho[1]

大家好, 今天继续分享 Uniswap V2 的学习心得, 今天的内容是 Uniswap[2]的交易算法

Uniswap 核心思想 A * B = K 在不考虑手续费的情况下, 交易前后 两个代币数量乘积不变

假设一个池子里有两个币种 tokenA 和 tokenB, 数量分别为 A 和 B 现在用户用 ∆A 的 tokenA 能够换到多少 tokenB 呢? 答案是: ∆B = B _ ∆A / (A ∆A) 这样交换后池子中 tokenA 的 余额为 A ∆A, tokenB 的余额为: B - B _ ∆A / (A ∆A) 两者乘积后依然等于 A*B

这个设定的好处是, 在交换数量极小的情况下, 用户的成交价格近似等于两个币种的库存比值:

∆A / ∆B = (A ∆A) / B ≈ A / B, 这样可以比较真实的反应供求关系

而在成交量极大时, ∆B 永远不可能超过 B, 因此池子永远不能被清空.

这个公式我们还可以理解为: 用户用 ∆A 个 tokenA 换成 ∆B 个 tokenB, 他们的比值 ∆A/∆B , 应该等于 市场上 tokenA 和 tokenB 的供应量之比

也许大家的第一直觉认为这是错误的, 下面我们举例说明

假设池子中有 10 个 tokenA 和 10 个 tokenB, 现在用户拿来 10 个 tokenA ,应该换到多少 tokenB 呢? 答案是 5 个 按照 A*B = K 的原则, 池子中 tokenA 翻了 1 倍, tokenB 应该变为原来的一半, 所以是 5 个没问题

有人会问, 但是池子中的 tokenA 和 tokenB 不是 1:1 吗? 如果兑换比例等于供应量之比应该是换出 10 个 tokenB 才对, 为什么只能换到 5 个呢?

注意, 我们说的 "市场上 tokenA 和 tokenB 的供应量之比" 不是池子中的数量之比, 而是池子中加上交易者手上的代币. 所以 A 的总供给应该是 20 个, tokenA 和 tokenB 总供给比例是 2:1 因为既然交易者拿了这 10 个 tokenA 来换 ,说明这部分筹码是浮动筹码, 需要进入市场流通的, 理应算入总供给.

也许又有人要问了, 既然要算总供给, 那么还有其他的潜在浮动筹码, 怎么不一起计算进来呢? 因为你说的 "潜在浮动筹码" 那是长期供求关系, 我们现在讨论一笔交易之内的定价, 那就只计算在这一笔交易之内可见的总供给.

综上所述. 一旦把交易者手中的代币也算入供给量, 那么按照 A*B=K, 兑换比例其实就是和总供给比例完全相同.

下面我们学习一下这个交易算法的实现代码

这个算法在两个地方都有实现,

一个是 core/UniswapV2Pair.sol 的 swap 函数:

代码语言:javascript复制
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
    ...

    if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
    if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens

    ...

    { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
        require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
    }
    ...
}

swap 函数代码我节选了一部分, 有兴趣的同学可以参考: https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol

注意 Pair 的 swap 函数并不会执行 transfer 操作, 它假定用户已经将输入币种 tranfer 到本合约地址(实际是在 Router 完成) 同时用户应该自己计算出输入币种的数量应该换取多少输出币种 amountOut(实际是在 Router 完成) ,注意参数中的 amount0Out 和 amount1Out 有一个是 0

开始的那两条 safeTransfer 语句是不是很大方呢? 你要多少 out 币种, 我就给你, 看起来是这样, 但是且慢.

最后的 require 语句就是验证 在扣除手续费后, 两个币种的余额的乘积不能小于交易前的乘积, 这就是 x*y = K 的实现代码. 所以如果你输入的金额如果不够满足条件, 那两条 tranfer 是要被回滚的.

如果用户往 Pair 合约 tranfer 了多余的代币怎么办? 很抱歉, 这条 require 只保证 x*y 不小于之前的值, 如果有剩余是不会退还的. 而实际在 Router 中会准确计算出 amountOut, 不会存在输入币种过剩的情况.

x*y = K 的第二个实现在 periphery/libraries/UniswapV2Library.sol 的 getAmoutOut 函数: (https://github.com/Uniswap/v2-periphery/blob/master/contracts/libraries/UniswapV2Library.sol)

代码语言:javascript复制
// given an input amount of an asset and pair reserves, returns the maximum output amount of the other asset
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;
}

这个函数的功能是, 给定 input 币种的数量 amountIn, 计算出 output 币种数量 amountOut

首先 amountIn 就会被扣除 0.3% 的手续费, 实际的有效输入就是 amountInWithFee = 0.997 _ amountIn 最后的输出金额 = reserveOut _ amountInWithFee / (reserveIn amountInWithFee),

这和我们之前的 ∆B = B * ∆A / (A ∆A) 保持一致. 注意在这里首先把分子分母都乘以 1000, 因为 evm 不支持浮点数.

Pair 合约中的 reserve 和 balance

pair 合约中有两个重要变量:

uint112 private reserve0; uint112 private reserve1;

这两个变量记录了当前资金池中两个代币的交易后余额, 或者叫结清余额/库存.

在 Pair 中并不存在 balance0, balance1 这两个变量, 但是我们可以在任何时候, 包括交易内部和外部, 使用 IERC20(token0 或 1).balanceOf(pair_address) 获取真实余额 Pair 的真实 balance 和 reserve 并不完全等同, 例如在添加流动性时, Router 会将用户的代币转到 pair 合约, 在交易结束之前, balance0 > reserve0, balance1 > reserve1, 而 Pair 合约正是通过其差值, 得以计算出用户发送代币的数量, 从而 mint 对应的 LP 代币给用户. 在每一笔可能涉及余额变化的交易之后, 都会执行更新 reserve 的操作, 使得 reserve = balance

因此,在交易之外, balance 总是等于 reserve, 交易内部则有可能不同.

更新 reserve 的操作写在 Pair 的 _update 函数中

代码语言:javascript复制
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    ...
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    ...
}

(这个函数还更新了一些统计信息, 如价格积分, 最后更新的时间戳等)

流动性添加和移除 流动性添加和移除的算法比较简单,

  • 添加流动性计算方法是, 用户发送 token0 , token1 到 pair, pair 根据 (balance-reserve)/reserve 决定应该 mint 多少比例的 LP 给用户 (这里用户发送的代币数量就是通过 balance-reserve 计算得出), 用户得到的 LP = LP_supply * (balance-reserve)/reserve 如果两个代币的充值的比值不同, 取较小的一个计算 mint 数量,确保平台不吃亏. 如果是第一次添加流动性, 那么获得的 LP = sqrt(balance0 _ balance1) - 1000 但是系统的 LP supply = sqrt(balance0 _ balance1), 其中 1000 wei 的 LP 被永久锁定 (确保资金池始终有库存). 具体代码可以查看 UniswapV2Router02.sol 的 addLiquidity() 函数 和 UniswapV2Pair.sol 的 mint() 函数
  • 涉及 ETH 的流动性添加和移除: 添加流动性时 Router 会将 ETH 换成 WETH, 再发送到 (WETH, token)交易对进行 mint 移除流动性时 Router 会将(WETH, token) 的 LP token 发送到 Pair, burn 掉 LP 后得到的 token 直接发送给用户, 得到的 WETH 换成 ETH 发送给用户. 具体代码可以查看 UniswapV2Router02.sol 的 addLiquidityETH() 和 removeLiquidityETH() 函数.

swap 交易流程: Router 的 swap 函数有很多个:

swapExactTokensForTokens # 输入确定数量 tokenIn, 输出待定 tokenOut

swapTokensForExactTokens # 输入待定 tokenIn, 输出确定数量 tokenOut

swapExactETHForTokens # 输入固定数量 ETH, 输出待定的目标币种

swapETHForExactTokens # 输入待定 ETH, 输出固定数量目标币种

swapExactTokensForETH # 输入固定数量币种, 输出待定的 ETH

swapTokensForExactETH # 输入待定数量币种, 输出固定的 ETH

swapExactTokensForTokensSupportingFeeOnTransferTokens # 输入固定数量 token0, 输出未知数量 token1

swapExactETHForTokensSupportingFeeOnTransferTokens # 输入固定数量 ETH, 输出未知数量 token1

swapExactTokensForETHSupportingFeeOnTransferTokens # 输入固定数量 token0, 输出未知数量 ETH

以上所有函数都是支持路径的兑换函数. 调用者可以指定兑换路径 path[address], 从第一个币种开始逐个兑到最后一个币种.

所有这些函数的实现, 其实都是使用以下内部函数完成的: getAmountsOut, getAmountsIn, getAmountOut, _swap, _swapSupportingFeeOnTransferTokens

其中 getAmountsOut 是给定输入代币数量, 以及路径 path[address], 计算出路径上每个代币输出数量 amountsOut[uint] 他的逻辑是依次计算路径上每个 Pair 的 amoutOut (按照本文开头的 x * y = K 原则计算兑换数量) getAmountsIn 与之类似.

另外, getAmountOut 计算的是单个交易对, 给定输入所能兑换到的输出币种数量, 注意和 getAmountsOut 的区别

下面简单介绍 Router 的 _swap 函数, 这个函数实际上会调用 Pair 的 swap:

代码语言:javascript复制
function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {
    for (uint i; i < path.length - 1; i  ) {
        (address input, address output) = (path[i], path[i   1]);
        (address token0,) = UniswapV2Library.sortTokens(input, output);
        uint amountOut = amounts[i   1];
        (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));
        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)
        );
    }
}

这个函数是一个支持兑换路径的 swap, 他会依次把 path 上的代币换成下一个代币, 直到换出最后一个代币发送给接收者.

这个函数被调用时, amounts[0]代表输入币种数量, amounts[1]到 amounts[len-1] 指定每个代币的输出数量, 这个 amounts 数组就是调用者函数(前面提到的 9 个 swapXXX 中的前 6 个 )预先使用 getAmountsOut(适用于 swapExactToXXX) 或者 getAmountsIn(适用于 swapXXXToExact) 计算出来的.

如果 path 路径中存在 fee-on-transfer 的代币 (前面 9 个 swapXXX 的后 3 个,) 则是通过调用 _swapSupportingFeeOnTransferTokens 实现兑换.

fee-on-transfer 是一些特殊的 ERC20, 在转账时会被收取手续费, 而手续费又是未知的, 因此无法使用 getAmountsOut(In) 预先计算路径上每个代币的换取数量, 所以 swap 的实现方法有所不同:

代码语言:javascript复制
function _swapSupportingFeeOnTransferTokens(address[] memory path, address _to) internal virtual {
    for (uint i; i < path.length - 1; i  ) {
        (address input, address output) = (path[i], path[i   1]);
        (address token0,) = UniswapV2Library.sortTokens(input, output);
        IUniswapV2Pair pair = IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output));
        uint amountInput;
        uint amountOutput;
        { // scope to avoid stack too deep errors
        (uint reserve0, uint reserve1,) = pair.getReserves();
        (uint reserveInput, uint reserveOutput) = input == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
        amountInput = IERC20(input).balanceOf(address(pair)).sub(reserveInput);
        amountOutput = UniswapV2Library.getAmountOut(amountInput, reserveInput, reserveOutput);
        }
        (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOutput) : (amountOutput, uint(0));
        address to = i < path.length - 2 ? UniswapV2Library.pairFor(factory, output, path[i   2]) : _to;
        pair.swap(amount0Out, amount1Out, to, new bytes(0));
    }
}

这个函数和前面的 _swap 相比, 少了一个参数 amounts[], 因为无法确定.

正因为如此, 我们看到, 所有带有 SupportingFeeOnTransferTokens 的 swap 函数, 都是 from Exact To Unkown, 没有 from Unkown To Exact 的情况

_swapSupportingFeeOnTransferTokens 具体逻辑是:

遍历路径上每一个 pair: 根据 balance - reserve 计算输入, 然后 使用 getAmountOut 得出这个 pair 的输出数量 amountOut 然后调用 pair 的 swap 换出目标代币, 如果当前 pair 是最后一个 pair, 那么接收者就是函数指定的接收者, 否则接收者是下一个 pair

很多同学可能有疑问: 不是说 fee-on-transfer 代币的 amountOut 无法计算吗, 为什么每个 pair 都能计算出 amountOut 呢?

注意这里的 getAmountOut 是获取单个 pair 的 输出数量, 在执行 getAmountOut 时, 当前 pair 已经收到代币, 转账手续费已经被扣掉了, 此时的 balance - reserve 就是 pair 已经确定收到的数量. 至于换成目标代币发送到下一个 pair 时, 是否会扣手续费, 那不重要, 因为下一个 pair 依然是按照自己收到的数量计算输出数量.

OK, 基本的 swap 原理搞清楚了, 那么前面 9 个 swap 的逻辑就很简单:

从 Exact 到 待定数量的 swap: 使用 getAmountsOut 计算路径上每一个币种的 out, 再调用 _swap 从 待定数量 到 Exact 的 swap: 使用 getAmountsIn 计算路径上每一个币种的 out, 再调用 _swap

对于涉及 ETH 的 swap, 如果 ETH 是输入币种, 那么首先调用 WETH.deposit 换成 WETH 再调用 _swap 如果 ETH 是输出币种, 那么调用 _swap 得到 WETH 后用 WETH.withdraw 换成 ETH 发送给用户

如果路径上存在 fee-on-transfer 的币种, 调用 _swap 改成调用 _swapSupportingFeeOnTransferTokens 函数

OK, 今天的内容基本介绍了 pair 和 router 的交易算法, 我们下期再见.

作者 mail:star4evar@gmail.com

参考资料

[1]

tony.ho: https://learnblockchain.cn/people/8619

[2]

Uniswap: https://learnblockchain.cn/article/2118

0 人点赞