Uniswap (Decentralized Exchange)

Table of Contents

1. Uniswap 简介

Uniswap 是 Ethereum 平台的去中心化交易所。提供了 ETH 和 ERC20 代币之间,以及 ERC20 代币和 ERC20 代币之间的兑换服务。

Uniswap V1 于 2018 年 11 月发布,其智能合约采用 Vyper 编写,合约源码在:https://github.com/Uniswap/uniswap-v1 。Uniswap V2 于 2020 年 5 月发布,其智能合约采用 Solidity 编写,合约源码在:https://github.com/Uniswap/uniswap-v2-corehttps://github.com/Uniswap/uniswap-v2-periphery 。V1 和 V2 采用的交易模型都是 x-y-k,后文会介绍。

本文以 Uniswap V2 为例进行介绍。

2. 基本使用步骤

在中心化的交易所中,用户可以创建“限价订单”,用户可以设置 Token 的兑换汇率,满足这个条件后系统才会完成订单。但 Uniswap 中没有这些概念。

在 Uniswap 中每个交易对(比如 Token A 和 Token B),都对应一个池。用户要实现 Token A 和 Token B 之间的兑换,首先这个池中要同时存在 Token A 和 Token B。

第一步:“流动性提供者”往池中增加流动性(Add Liquidity)。
如果某个交易对已经存在,则我们可以往相应的池“等比率地”(比如池中 Token A 和 Token B 目前的量分别为 100 和 10,那么增加流动性时也只能按这个比率来增加)充入更多的 Token A 和 Token B,以增加流动性。如果交易对还不存在,则第一个增加流动性的人可以确定 Token A 和 Token B 之间的兑换比率。提供流动性时,智能合约会返回一个新 Token(记为 Liquidity Provider Token,简称 LP Token)给“流动性提供者”。凭 LP Token,可以从池中“等比率地”取出 Token A 和 Token B,这称为“删除流动性”。

第二步:“普通用户”进行交易(Swap)。
用户可以在池中进行 Token 的兑换。兑换的基本规则是 \(x \times y = k\) ,它的意思是 池中的 Token A 和 Token B 的数量乘积始终维持一个常数。 举例来说,假设池中有 100 个 Token A 和 10 个 Token B,如果用户想从池中获得 2 个 Token B,那么他需要付出多少个 Token A 放入池中呢?假设需要付出 \(n\) 个 Token A,维持兑换前后 Token A 和 Token B 的数量乘积不变,则有 \((100 + n) \times (10 - 2) = 100 \times 10\) ,从而 \(n=25\) ,这个交易结束后,池中的 Token A 为 125 个,而 Token B 为 8 个。如果用户还想从池中获得 2 个 Token B,那么这一次他需要付出多少个 Token A 放入池中呢?假设需要付出 \(m\) 个 Token A,维持兑换前后 Token A 和 Token B 的数量乘积不变,则有 \((125 + m) \times (8 - 2) = 125 \times 8\) ,从而 \(m=41.6667\) ,这个交易结束后,池中的 Token A 为 166.6667 个,而 Token B 为 6 个。注:这里讨论的是没有交易费的情况。

注:当引入手续费后,随着交易的频繁进行,池中会积攒更多的 Token,这样流动性提供者在“删除流动性”时,很可能获得比“提供流动性时所充入的 Token”还要多的 Token,额外多出的部分就是流动性提供者的利润。

2.1. LP Token 的发行

流动性提供者往池中增加流动性时,会发行一定数量的 LP Token 给流动性提供者。每个交易对都对应一个不同的 LP Token。

如果池已经存在,则发行的 LP Token 数量由下面公式决定:
\[S_{minted} = \frac{x_{deposited}}{x_{starting}} \cdot s_{starting}\]
上式中, \(x_{deposited}\) 表示这次增加流动性时存入的 Token A 数量, \(x_{starting}\) 第一次增加流动性时存入的 Token A 数量, \(s_{starting}\) 表示第一次发行的 LP Token 数量。

如果池不存在(也就是说第一次发行的 LP Token 数量),则下面公式决定:
\[S_{minted} = \sqrt{x_{deposited} \cdot y_{deposited}}\]

比如,第一次添加交易对时,存入了 1 个 Token A 和 100 个 Token B,则会给你 \(\sqrt{1 \cdot 100} = 10\) 个 LP Token。如果第一次添加交易对时,存入了 2 个 Token A 和 200 个 Token B,则会给你 \(\sqrt{2 \cdot 200} = 20\) 个 LP Token。

3. x-y-k 模型(常量乘积做市商模型)

Uniswap 使用的是 x-y-k 做市商模型,实现 Token 之间的自动交易。节 2 中已经介绍过了没有手续费时 x-y-k 做市商模型的基本情况。

3.1. 无手续费情况

假设池中 Token A 和 Token B 的数量目前分别为 \(x\) 和 \(y\) 。

情况一:用户想从池中得到 \(\Delta y\) 个 Token B,那么用户需要付出的 Token A 数量记为 \(\Delta x\) ,则 \(\Delta x\) 可以这样计算(即池中的 Token A 和 Token B 的数量乘积始终维持一个常数):
\[(x + \Delta x) (y - \Delta y) = xy\]
从而:

\begin{align*} \Delta x &= x - \frac{xy}{y - \Delta y} \\ &= \frac{\Delta y}{y - \Delta y} x \end{align*}

如果定义 \(\beta = \frac{\Delta y}{y}\) ,则上式可写为:
\[\Delta x = \frac{\beta}{1 - \beta} x\]

情况二:用户往池中放入 \(\Delta x\) 个 Token A,那么用户可以得到的 Token B 的数量记为 \(\Delta y\) ,则 \(\Delta y\) 可以这样计算:
\[(x + \Delta x) (y - \Delta y) = xy\]
从而:

\begin{align*} \Delta y &= y - \frac{xy}{x + \Delta x} \\ &= \frac{\Delta x}{x + \Delta x} y \end{align*}

如果定义 \(\alpha = \frac{\Delta x}{x}\) ,则上式可写为:
\[\Delta y = \frac{\alpha}{1 + \alpha} y\]

3.2. 有手续费情况

假设用户交易时,要收 \(\rho\) 的手续费。比如 \(\rho = 0.003\) 时,表示收 \(0.3 \%\) 的手续费。

情况一:用户想从池中得到 \(\Delta y\) 个 Token B,收手续费情况下,用户需要付出的 Token A 数量记为 \(\Delta x\) 。如果不收手续费的话,上一节算出了 \(\Delta x_{\text{no fee}} = \frac{\beta}{1-\beta}x\) ,现在收手续费,显然用户要付出得更多了,设手续费为 \(\rho\) ,那么有:
\[(1 - \rho) \Delta x = \Delta x_{\text{no fee}}\]
从而:
\[\Delta x = \frac{1}{1 - \rho} \frac{\beta}{1 - \beta} x\]

上式就是由 \(x,y,\Delta y,\rho\) 求 \(\Delta x\) 的公式,它就是节 3.2.2 中函数 getAmountIn 所使用的公式。

情况二:用户往池中放入 \(\Delta x\) 个 Token A,收手续费情况下,用户可以得到的 Token B 的数量记为 \(\Delta y\) 。由前面得到的公式有:
\[\Delta x = \frac{1}{1 - \rho} \frac{\frac{\Delta y}{y}}{1 - \frac{\Delta y}{y}} x\]
从而推出:

\begin{align*} \Delta y &= \frac{\Delta x (1 - \rho) y}{x + \Delta x (1 - \rho)} \\ &= \frac{\alpha(1 - \rho)}{1 + \alpha(1 - \rho)} y \end{align*}

上式就是由 \(x,y,\Delta x,\rho\) 求 \(\Delta y\) 的公式,它就是节 3.2.2 中函数 getAmountOut 所使用的公式。

3.2.1. k 会一直增加

池中两个 Token 的现有数量分别为 \(x,y\) ,一次交易完成后,池中两个 Token 的数量记为 \(x'_{\rho}\) 和 \(y'_{\rho}\) ,以前面的“情况一”的公式为例,计算一下 \(x'_{\rho}\) 和 \(y'_{\rho}\) :

\begin{align*} x'_{\rho} &= x + \Delta x \\ &= (1 + \frac{1}{1 - \rho} \frac{\beta}{1-\beta} )x \\ &= \frac{1 + \beta(\frac{1}{1 - \rho} - 1)}{1-\beta} x \\ y'_{\rho} &= y - \Delta y \\ &= (1 - \beta) y \end{align*}

从而:
\[x'_{\rho} \cdot y'_{\rho} = \left(1 + \beta(\frac{1}{1 - \rho} - 1) \right) x \cdot y\]

上式表示,引入交易手续费后,每次交易完成时, \(k\) (即 \(xy\) )不再是常量,而是以 \(1 + \beta(\frac{1}{1 - \rho} - 1)\) 的倍数增加。

3.2.2. 智能合约代码实现

情况一:池中 Token A 和 Token B 目前分别有 reserveIn(即 \(x\) )和 reserveOut(即 \(y\) )个,手续费 \(\rho = 0.003\) ,用户想从池中得到 amountOut(即 \(\Delta y\) )个 Token B,那么需要付出的 Token A 的数量由智能合约函数 getAmountIn 得到:

    // given an output amount of an asset and pair reserves, returns a required input amount of the other asset
    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);
    }

计算细节如下:

\begin{align*} \Delta x &= \frac{1}{1 - \rho} \frac{\beta}{1 - \beta} x \\ &= \frac{1}{0.997} \frac{\Delta y}{y - \Delta y} x \\ &= \frac{1000 \Delta y x}{997 (y - \Delta y)} \\ &= {\Big \lfloor } \frac{1000 \Delta y x}{997 (y - \Delta y)} {\Big \rfloor } + 1 \end{align*}

说明,Solidity 不支持浮点运算,除法只会得到整数部分,余数部分会丢掉。为了避免出现 0,在结果上人为加了 1。

情况二:池中 Token A 和 Token B 目前分别有 reserveIn(即 \(x\) )和 reserveOut(即 \(y\) )个,手续费 \(\rho = 0.003\) ,用户往池中放入 amountIn(即 \(\Delta x\) )个 Token A,那么可以获得的 Token B 的数量由智能合约函数 getAmountOut 得到:

    // 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;
    }

计算细节如下:

\begin{align*} \Delta y &= \frac{\Delta x (1 - \rho) y}{x + \Delta x (1 - \rho)} \\ &= \frac{\Delta x 0.997 y}{x + \Delta x 0.997} \\ &= \frac{997 \Delta x y}{1000 x + 997 \Delta x} \end{align*}

这个地方,并没有向前面那样考虑整数除法时余数部分被丢掉的问题,计算出来的 \(\Delta y\) 可能为 0。

智能合约函数 getAmountIn 和 getAmountOut 的源码摘自:https://etherscan.io/address/0x7a250d5630b4cf539739df2c5dacb4c659f2488d#code

4. Uniswap V2 合约接口

调用接口 addLiquidity 可以添加流动性,调用接口 removeLiquidity 可以删除流动性:

addLiquidity(address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin, address to, uint deadline ) external returns (uint amountA, uint amountB, uint liquidity)
removeLiquidity(address tokenA, address tokenB, uint liquidity, uint amountAMin, uint amountBMin, address to, uint deadline) external returns (uint amountA, uint amountB)

1addLiquidity 各个参数的说明。

Table 1: addLiquidity 的各个参数
参数 含义
tokenA Token A 代币合约地址
tokenB Token B 代币合约地址
amountADesired 想存入多少 tokenA
amountBDesired 想存入多少 tokenB
amountAMin 用于限制对价格的影响。不关心,可设置为 0
amountBMin 用于限制对价格的影响。不关心,可设置为 0
to 增加流动性后,会得到 LP 代币,这是接收 LP 代币的地址
deadline 这是一个未来的 Unix timestamp,超过这个时间没有打包就会放弃交易

使用 addLiquidity 第 1 次对某个交易对增加流动性时,会自动部署 LP 代币合约。存入的 Token A 和 Token B 都会记做是 LP 合约地址名下。

下面几个接口可以实现 ETH 和 ERC20 代币之间,以及 ERC20 代币和 ERC20 代币之间的兑换:

swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)
swapTokensForExactTokens(uint amountOut, uint amountInMax, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)
swapExactETHForTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)
swapTokensForExactETH(uint amountOut, uint amountInMax, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)
swapExactTokensForETH(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)
swapETHForExactTokens(uint amountOut, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)

下面介绍一下接口 swapExactTokensForTokens,它表示使用固定数量(由 amountIn 指定)的 Token A(由 path[0] 指定)去兑换 Token B(由 path[length-1] 指定),把得到的 Token B 存入到由参数 to 指定的地址,其各个参数如表 2 所示。

Table 2: swapExactTokensForTokens 的各个参数
参数 含义
amountIn The amount of input tokens to send.
amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert.
path An array of token addresses. path.length must be >= 2.
to Recipient of the output tokens.
deadline Unix timestamp after which the transaction will revert.

比如,我们要用 100 个 USDC 去兑换 USDT,把收到的 USDT 放入地址 0xXX,那么参数可以分别设置为表 3 所示。

Table 3: swapExactTokensForTokens 参数实例
参数 实例 说明
amountIn 100 * 10^6 USDC 的精度是 6
amountOutMin 95 * 10^6 USDT 的精度是 6,设置如果收到的 USDT 少于 95 个,则放弃交易。这是最小阈值,不是成交值,所以可以设置为 0
path 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 0xdAC17F958D2ee523a2206206994597C13D831ec7 分别为 USDC,USDT 的地址
to 0xXX 接收 USDT 的地址,请修改为合法的以太坊地址
deadline now.add(1800) 半小时交易没打包就放弃

参考:https://docs.uniswap.org/contracts/v2/reference/smart-contracts/router-02

5. 部署 Uniswap V2 合约

下面介绍如何从零开始部署一套 Uniswap 合约。

用户直接使用的是 UniswapV2Router02 合约,该合约会调用 UniswapV2Factory 合约,我们只需要部署这两个合约。可以从 etherscan 上获取这两个合约的源码并使用 Remix IDE 部署(etherscan 中的源码已经把分散的 Uniswap 合约整合到了一起,这样部署更方便)。

需要注意的是: UniswapV2Router02 合约中写死了“init code hash”,需要修改它为 “UniswapV2Pair 的 bytecode 的 keccak256 哈希” (注:在 Remix IDE 中编译 UniswapV2Factory 后,可以复制出合约 UniswapV2Pair 的 bytecode,然后找个工具计算它的 keccak256 哈希后,再替换一下即可)。

    // calculates the CREATE2 address for a pair without making any external calls
    function pairFor(address factory, address tokenA, address tokenB) internal pure returns (address pair) {
        (address token0, address token1) = sortTokens(tokenA, tokenB);
        pair = address(uint(keccak256(abi.encodePacked(
                hex'ff',
                factory,
                keccak256(abi.encodePacked(token0, token1)),
                hex'96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f' // init code hash, change this!
            ))));
    }

要进行这个修改的具体原因如下:在增加交易对时,UniswapV2Factory 会使用 CREATE2 创建 UniswapV2Pair 合约的实例;但 UniswapV2Router02 合约并没有从 UniswapV2Factory 合约中去查询新创建的合约地址,而是根据 CREATE2 生成合约地址的方式自己重新算出地址(这种方式比跨合约查询要更省 gas)。CREATE2 指令有一个参数:init code hash(这个参数变化时,CREATE2 创建的合约地址也会发生变化),这个例子中参数 init code hash 是“UniswapV2Pair 的 bytecode 的 keccak256 哈希”(注:CREATE2 的参数 init code hash 是“合约 bytecode 再加上 ABI encode 编码后的构造函数参数”的 keccak256 值,但由于 UniswapV2Pair 的构造函数没有参数,所以这里不用考虑构造函数参数,直接用 bytecode 算 keccak256 即可)。由于“UniswapV2Pair 的 bytecode”这个值会随编译器的不同设置而发生改变,所以要把 init code hash 相应地修改为真实部署时的“UniswapV2Pair 的 bytecode 的 keccak256 哈希”。

详细部署过程如下:
第 1 步,编译 UniswapV2Factory 合约(包含了 UniswapV2Pair 合约),在 Remix 中复制出合约 UniswapV2Pair 的 bytecode,找个工具计算它的 keccak256 哈希,后面会用到。

第 2 步,部署 UniswapV2Factory 合约,它的构造函数为:

    constructor(address _feeToSetter) public {
        feeToSetter = _feeToSetter;
    }

没特别要求,把 _feeToSetter 设置为部署者自己的地址即可。部署成功后记下刚部署的 UniswapV2Factory 合约的地址,后面部署 UniswapV2Router02 时需要用到。

第 3 步,修改 UniswapV2Router02 合约中的 init code hash 为第 1 步计算出来的值。

第 4 步,编译 UniswapV2Router02 合约,并部署,它的构造函数为:

    constructor(address _factory, address _WETH) public {
        factory = _factory;
        WETH = _WETH;
    }

它需要两个参数,一个是刚部署的 UniswapV2Factory 合约地址,另一个是 WETH 的地址(可以找现有的 WETH 或者自行部署一个 WETH 也行)。

6. 无常损失(Impermanent Loss)

当用户向某交易对的资金池添加流动性后,当价格上涨或者下跌时,用户删除流动性后所得的资产与单纯持有两种资产相比会出现一定的损失,这个损失叫做无常损失(Impermanent Loss)。

下面通过例子来对此进行说明。假设交易对为 SUSHI 和 USDT,用户添加流动性时,1 SUSHI 兑换 50 USDT;用户往池中添加了 10 SUSHI 和 500 USDT,该用户占总流动性池的 10%;这时,用户的总资产价值为 1000 USDT。

假设 SUSHI 价格发生了变化,涨为了 200 USDT,根据恒定常数自动做市商的机制,总流动性池中资产的量变为 50 SUSHI 与 10000 USDT,若此时用户删除流动性,由于该用户提供的流动性占总流动性池的 10%,则该用户可取出 5 SUSHI 与 1000 USDT,此时用户的资产价值为 2000 USDT(这里不考虑用户的手续费收入)。

但如果用户不提供流动性,而是单纯持有 10 SUSHI 和 500 USDT,则用户资产的价值为 10 * 200 + 500 = 2500 USDT,中间的损失为 500 USDT,提供流动性与单纯持有之间相较产生的损失称为无常损失 。

显然,仅当删除流动性时的两币种兑换比率和添加流动性时的兑换比率保持不变时,才没有无常损失。

参考:https://docs.uniswap.org/contracts/v2/concepts/advanced-topics/understanding-returns

7. 滑点(Price Slippage)

Slippage 的定义是:

The amount the price moves in a trading pair between when a transaction is submitted and when it is executed.

Slippage 细节可参考节 8.6

8. Price Impact

Price Impact 的定义是:

The difference between the mid-price and the execution price of a trade.

mid-price 这个名称来源于传统金融市场中的概念。在传统金融市场中, mid price(中间价)是指:买入价(bid price)和卖出价(ask price)的中间值。 不过在 Uniswap 中,并不是传统金融交易搓合的模式,是没有显式的买入价和卖出价的,但还是沿用了 mid-price 这个概念。

Price Impact 是用户“看到的价格(Expected Price)”和“执行交易时的实际价格(Actual Price)”的差别,一般用百分比来表示这个差别。

\[\text{Price Impact} = \frac{\text{Expected Price} - \text{Actual Price}}{\text{Expected Price}}\]

8.1. 从对 Input Token 价格影响的角度

池中有两种代币, 当我们说 Price Impact 时,一般是从用户卖出 Token(即 Input Token)价格变化的角度。

假设池中 Token A 和 Token B 的数量目前分别为 \(x\) 和 \(y\) 。

用户在某个 Swap 交易中卖掉 \(\Delta x\) 个 Token A,在交易完成时用户得到了 \(\Delta y\) 个 Token B。这个 Swap 交易用户卖 Token A 的真实价格为 \(\frac{\Delta y}{\Delta x}\) 。而执行交易前,用户看到的 Token A 价格是 \(\frac{y}{x}\) 。所以,Price Impact 可以采用下面公式:

\begin{align*} \text{Price Impact} &= \frac{\frac{y}{x} - \frac{\Delta y}{\Delta x}}{\frac{y}{x}} \\ &= 1 - \frac{x \cdot \Delta y}{\Delta x \cdot y} \end{align*}

在上式中代入节 3.1 中计算 \(\Delta y\) 的公式 \[\Delta y = \frac{\Delta x}{x + \Delta x} y\]

从而有(这个公式可直接从 \(\Delta x\) 和 \(x\) 计算 Price Impact):

\begin{align*} \text{Price Impact} &= 1 - \frac{x \cdot \Delta y}{\Delta x \cdot y} \\ &= \frac{\Delta x}{x + \Delta x} \end{align*}

8.2. 从对 Output Token 数量影响的角度

我们把上一节介绍的公式稍微变一下形式

\begin{align*} \text{Price Impact} &= 1 - \frac{x \cdot \Delta y}{\Delta x \cdot y} \\ &= 1 - \frac{\Delta y}{ \Delta x \cdot \frac{y}{x}} \\ &= \frac{\Delta x \cdot \frac{y}{x} - \Delta y}{ \Delta x \cdot \frac{y}{x}} \end{align*}

Uniswap 代码中就是采用了上面的公式:

// From https://github.com/Uniswap/sdk-core/blob/main/src/utils/computePriceImpact.ts#L9

/**
 * Returns the percent difference between the mid price and the execution price, i.e. price impact.
 * @param midPrice mid price before the trade
 * @param inputAmount the input amount of the trade
 * @param outputAmount the output amount of the trade
 */
export function computePriceImpact<TBase extends Currency, TQuote extends Currency>(
  midPrice: Price<TBase, TQuote>,
  inputAmount: CurrencyAmount<TBase>,
  outputAmount: CurrencyAmount<TQuote>
): Percent {
  const quotedOutputAmount = midPrice.quote(inputAmount)
  // calculate price impact := (exactQuote - outputAmount) / exactQuote
  const priceImpact = quotedOutputAmount.subtract(outputAmount).divide(quotedOutputAmount)
  return new Percent(priceImpact.numerator, priceImpact.denominator)
}

从上面代码可知,设用户往池中加入 Input 数量为 inputAmount( \(\Delta x\) ),预计用户可得到的 Output 数量为 outputAmount( \(\Delta y\) )。代码中的 quotedOutputAmount 是假设价格不变时,用户理论上可以得到的 Output 数量( \(\Delta x \cdot \frac{y}{x}\) )。也就是说 Price Impact 还可以从对 Output Token 数量影响的角度来计算: \[\begin{aligned} \text{Price Impact} &= \frac{\text{价格不变时理论上可得到的 Output 数量} - \text{预计 Output 数量}}{\text{价格不变时理论上可得到的 Output 数量}} \\ &= 1 - \frac{\text{预计 Output 数量}}{\text{价格不变时理论上可得到的 Output 数量}} \\ &= 1 - \frac{\Delta y}{ \Delta x \cdot \frac{y}{x}}\end{aligned}\]

注:Uniswap 前端代码中还有基于 Token 法币价值来计算 Price Impact 的代码,参考:computeFiatValuePriceImpact

8.3. Price Impact 计算实例

下面通过一个例子来理解 Price Impact。

假设,当前池中的情况为:
Token A (USDC) 数量为 \(x = 2,000,000\)
Token B (ETH) 数量为 \(y = 1,000\)
\(k = xy = 2,000,000,000\)
USDC Price = \(\frac{y}{x}=\frac{1}{2000}\)
ETH Price = \(\frac{x}{y}=2000\)

实例 1:现在用户往池中加入 10,000 USDC 去买 ETH。那么 Swap 以后,池中情况为:
Token A (USDC) 数量为 \(x + \Delta x = 2,000,000 + 10,000 = 2,010,000\)
Token B (ETH) 数量为 \(y - \Delta y = \frac{k}{x + \Delta x} = 995.024876\)
\(k = 2,000,000,000\)

也就是说,用户会收到 ETH 4.975124 个。则有 \[\text{Price Impact} = \frac{\frac{\Delta x}{\frac{x}{y}} - \Delta y}{{\frac{\Delta x}{\frac{x}{y}}}} = \frac{\frac{10,000}{\text{ETH Price}} - 4.975124}{\frac{10,000}{\text{ETH Price}}} = \frac{5-4.975124}{5} = 0.49752\%\]

实例 2:还是之前的例子,但这一次用户往池中加入实例 1 十倍的数量 USDC 即 100,000 USDC 去买 ETH。那么 Swap 以后,池中情况为:
Token A (USDC) 数量为 \(x + \Delta x = 2,000,000 + 100,000 = 2,100,000\)
Token B (ETH) 数量为 \(y - \Delta y = \frac{k}{x + \Delta x} = 952.380952\)
\(k = 2,000,000,000\)

也就是说,用户会收到 ETH 47.619048 个。则有 \[\text{Price Impact} = \frac{\frac{100,000}{\text{ETH Price}} - 47.619048}{\frac{100,000}{\text{ETH Price}}} = \frac{50-47.619048}{50} = 4.761904\%\]

也就是说实例 2 的 Price Impact 要大很多。

8.4. 已经最大 Price Impact 计算最小 Input Token 数量

假设用户希望 Price Impact 不超过 \(\theta\) ,那么最多投入多少 USDC 去买 ETH 呢?即求 \(\Delta x\) 的最大值是多少。

即已知 \[\frac{\frac{\Delta x}{\frac{x}{y}} - \Delta y}{{\frac{\Delta x}{\frac{x}{y}}}} \le \theta\] 求 \(\Delta x\) 的最大值。

因为 \((x+\Delta x)(y- \Delta y) = xy\) ,所以 \[\Delta y = y - \frac{xy}{x + \Delta x}\] 我们代入 \(\Delta y\) 到前面的不等式中,可以得到 \[\Delta x \le x \frac{\theta}{1 - \theta}\]

8.5. 更多 Price Impact 计算实例

假设池子中初始是 100 个 Token A 和 100 个 Token B,用户进行了 10 次交易,每一次都是卖掉 100 个 Token A(即往池中每次增加 \(\Delta x=100\) 个),则每次交易的 Price Impact 采用公式

\begin{align*} \text{Price Impact} &= 1 - \frac{x \cdot \Delta y}{\Delta x \cdot y} \\ &= \frac{\Delta x}{x + \Delta x} \end{align*}

容易计算出表 4 所示结果。

Table 4: 滑点计算实例
Swap Tx Token A (\(x\)) Token B (\(y\)) Trade A (\(\Delta x\)) Receive B (\(\Delta y\)) Price Impact
1 100 100 100 50 50%
2 200 50 100 16.66667 33%
3 300 33.33333 100 8.333333 25%
4 400 25 100 5 20%
5 500 20 100 3.333333 17%
6 600 16.66667 100 2.380952 14%
7 700 14.28571 100 1.785714 13%
8 800 12.5 100 1.388889 11%
9 900 11.11111 100 1.111111 10%
10 1000 10 100 0.909091 9%

需要说明的是,上面考虑的仅仅是没有手续费的 x-y-k 模型。

上面例子的数据来源于(不过网页中它是说计算 Slippage,但实际上计算的是 Price Impact):https://web.archive.org/web/20240919182050/https://devweb3.net/how-slippage-works-in-uniswap-v2/

8.6. Price Slippage VS. Price Impact

Price Slippage 和 Price Impact 的区别:

Price impact is the change in token price caused by your own trade. Price impact is the difference between the current market price and how your trade impacts the total liquidity in a pool.

Price slippage is the difference between the price you expect to receive after swapping and what you actually receive after the swap is complete.

参考:https://support.uniswap.org/hc/en-us/articles/8643794102669-Price-Impact-vs-Price-Slippage

Price slippage refers to the change in price caused by external broad market movements (unrelated to your trade), while price impact refers to the change in price directly caused by your own trade itself.

参考:https://help.1inch.io/en/articles/4585109-what-is-price-impact-vs-price-slippage-in-defi#h_8834d7e8c4

总结:

  1. 滑点产生的原因是:你准备执行交易,和真实执行交易的这段时间内,有其它人插入了交易。
  2. Price Impact 是由于池子流动性太小(或者你的交易量太大)导致。

假设池中 Token A/Token B 数量都为 100,你往池中放入 100 个 Token A,这时期望得到 Token B 的数量是 50。这个 Price Impact 是非常大的,而如果没有其它人插入交易,则没有滑点(因为得到 Token B 的数量比较少只有 50,这个事实在执行前就告诉用户了)。

9. Price Oracle

怎么得到一个不易被操控的 Token 价格呢?Uniswap V2 中给出了 Time-Weighted Average Prices (TWAPs) 方案。

不过,并没有一个现成的 API 可以直接使用,需要开发者根据 UniswapV2Pair 合约中的下面两个状态变量:

price0CumulativeLast
price1CumulativeLast

自己去构造 Price Oracle,构造方法可参考:https://docs.uniswap.org/contracts/v2/guides/smart-contract-integration/building-an-oracle

下面是构造 Price Oracle 时的几种常用策略:

  1. Fixed windows, data freshness is not important and recent prices are weighted equally with historical prices. 这种策略的例子可参考: https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/examples/ExampleOracleSimple.sol
  2. Simple moving averages, give equal weight to each price measurement. 这种策略的例子可参考:https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/examples/ExampleSlidingWindowOracle.sol
  3. Exponential moving averages, give more weight to the most recent price measurements.

上面 3 种策略实现时都需要开发者自己定期地“维护”Price Oracle。以上面第 1 个策略为例,“维护”Price Oracle 就是开发者要周期性地调用例子合约中的 update 方法:

// From https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/examples/ExampleOracleSimple.sol

function update() external {
    (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) =
        UniswapV2OracleLibrary.currentCumulativePrices(address(pair));
    uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired

    // ensure that at least one full period has passed since the last update
    require(timeElapsed >= PERIOD, 'ExampleOracleSimple: PERIOD_NOT_ELAPSED');

    // overflow is desired, casting never truncates
    // cumulative price is in (uq112x112 price * seconds) units so we simply wrap it after division by time elapsed
    price0Average = FixedPoint.uq112x112(uint224((price0Cumulative - price0CumulativeLast) / timeElapsed));
    price1Average = FixedPoint.uq112x112(uint224((price1Cumulative - price1CumulativeLast) / timeElapsed));

    price0CumulativeLast = price0Cumulative;
    price1CumulativeLast = price1Cumulative;
    blockTimestampLast = blockTimestamp;
}

也有一种不需要进行定期维护的 Price Oracle,不过这种方案只能利用最近 256 个区块中的 price0CumulativeLast/price1CumulativeLast 数据,这种方案可参考:https://github.com/Keydonix/uniswap-oracle

10. Flash Swap

使用 Uniswap V2 的 Flash Swap 本质是调用 UniswapV2Pair 合约的 swap 方法,它的源码如下:

    // this low-level function should be called from a contract which performs important safety checks
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
        require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

        uint balance0;
        uint balance1;
        { // scope for _token{0,1}, avoids stack too deep errors
        address _token0 = token0;
        address _token1 = token1;
        require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
        }
        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');
        { // 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');
        }

        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

普通的代币兑换的底层也会调用 swap 函数,这和 Flash Swap 的区别在于 data.length 是否为 0。从上面代码中可知, 如果 data.length 大于 0,则会回调 to 所指定的合约地址中的 uniswapV2Call 函数,这就是 Flash Swap。 data 参数会原封不动地传给 uniswapV2Call,在使用 Flash Swap 时,尽管有时我们不需要这个额外的参数,但调用 swap 时也必须给 data 参数传一字节的无用数据(从而实现 data.length 大于 0)。在函数 uniswapV2Call 中,用户可以实现自己的业务逻辑,且需要归还所借代币外加手续费到 UniswapV2Pair 合约中,uniswapV2Call 的实现例子可以参考:https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/examples/ExampleFlashSwap.sol

11. 参考

Author: cig01

Created: <2020-09-05 Sat>

Last updated: <2025-05-24 Sat>