UniswapV2-价格与流动性

参考资料:

https://updraft.cyfrin.io/courses/uniswap-v2/

https://github.com/kpyaoqi/UniswapV2_Chinese

价格滑点

现货价格

现货价格(Spot Price)是指流动性池中当前的代币兑换比率,反映了代币对的市场价格。它是一个动态值,随着流动性池中代币余额的变化实时调整。

在 Uniswap 中,现货价格的定义为:

现货价格(Spot Price)=y0x0\text{现货价格(Spot Price)} = \frac{y_0}{x_0}
  • y0y_0 是池中代币 yy 的数量;
  • x0x_0 是池中代币 xx 的数量。

例如,如果池中 x0=6,000,000x_0 = 6,000,000 DAI,y0=3000y_0 = 3000 ETH,则现货价格为:

Spot Price=30006,000,000=0.0005 ETH/DAI\text{Spot Price} = \frac{3000}{6,000,000} = 0.0005\ \text{ETH/DAI}

现货价格的意义:

  1. 如果代币 xx 是 DAI,代币 yy 是 ETH,那么现货价格 y0x0\frac{y_0}{x_0} 反映了 1 单位 DAI 兑换多少单位 ETH。
  2. 现货价格直接反映了 AMM 池子的状态,但并不等同于实际交易的执行价格。

image-20250109234740927

执行价格

执行价格(Execution Price)是用户在实际交易中得到的代币兑换比率,也称为 交易价格,定义为:

执行价格(Execution Price)=交易中实际输出的代币量(dy)交易中实际输入的代币量(dx)\text{执行价格(Execution Price)} = \frac{\text{交易中实际输出的代币量}(-dy)}{\text{交易中实际输入的代币量}(dx)}

相比现货价格,执行价格会受到交易规模和滑点的影响。执行价格由池子的状态变化决定,公式为:

Execution Price=dydx=交易曲线的斜率\text{Execution Price} = \frac{-dy}{dx} = \text{交易曲线的斜率}

image-20250109234918476

滑点

滑点(Slippage)是指 执行价格现货价格 之间的差异,通常以百分比形式表示:

滑点(Slippage)=Execution PriceSpot PriceSpot Price×100%\text{滑点(Slippage)} = \frac{\text{Execution Price} - \text{Spot Price}}{\text{Spot Price}} \times 100\%

滑点的大小取决于:

  1. 交易规模:交易量越大,对池子的价格影响越显著,滑点越大。
  2. 流动性:流动性越高,价格曲线越平滑,同样的交易量会导致更小的滑点。

滑点的原因

  1. 交易曲线斜率的变化:

    • 现货价格对应的是交易曲线的局部斜率,而执行价格是实际交易产生的代币比率。由于池子采用恒定乘积公式 xy=kx \cdot y = k,随着交易量的增加,曲线变得更陡,滑点也随之增加。
  2. 市场流动性有限:

    • 滑点本质上是流动性不足的体现。当池子中的 x0x_0y0y_0 较小时,即池子规模较小时,较大的交易会显著改变池子的代币余额,从而导致更高的滑点。
  3. 连续多笔交易的影响(市场移动):

    • 下图中显示了两个交易者(Alice 和 Bob)的连续交易场景:

      • Alice 提交交易,提交 20002000 DAI,获得 0.990.99 ETH;

      • Bob 紧接着提交交易,但由于池子的状态已被 Alice 的交易改变,Bob 的交易价格进一步变差,导致滑点更高。

image-20250109235114206

改进滑点的措施

  1. 增加流动性

流动性越高,交易曲线越平滑,单笔交易对池子的影响越小,从而减少滑点。例如,当池子从 20002000 DAI / 11 ETH 增加到 200,000200,000 DAI / 100100 ETH 时,同样的 20002000 DAI 交易会产生更小的滑点。

  1. 选择流动性更大的池子

如果某个代币交易对有多个流动性池,选择流动性更大的池子进行交易,可以有效减少滑点。

  1. 设置滑点保护

设置滑点保护上限(Slippage Tolerance),一旦滑点超出用户设定的范围,交易将被取消。

时间加权价格

时间加权平均价格(TWAP)是 Uniswap V2 中的一项重要功能,用于提供更稳定和抗操纵的价格指标。TWAP 通过在一段时间内计算价格平均值,减少了短期价格波动和操纵的影响。

image-20250126130835439

原理

Uniswap V2 中的每个交易对都维护一个累积价格变量,该变量随着时间的推移不断更新。通过使用这些累积价格,可以计算出任意时间段内的平均价格。

  1. 累积价格

    • 每个区块结束时,Uniswap 会更新交易对的累积价格。
    • 累积价格是代币价格乘以时间的总和。
    • 记录两个累积价格:一个针对 token0,另一个针对 token1
  2. 计算 TWAP

    • 选择两个时间点,分别获取这两个时间点的累积价格。
    • 通过累积价格的差值除以时间差,得到这段时间内的平均价格。

使用场景

  • 价格预言机:TWAP 可以用于构建去中心化的价格预言机,提供抗操纵的价格参考。
  • 风险管理:通过平均价格进行交易,减少价格剧烈波动带来的风险。
  • 套利检测:识别市场上短期价格异常,进行套利操作。

实现细节

  1. 累积价格更新

    • 每个交易对在每个区块结束时更新累积价格。
    • 累积价格是价格乘以时间的总和。
    • Uniswap 维护两个累积价格:price0CumulativeLastprice1CumulativeLast
  2. 计算公式

    • 假设 P0P1 是两个时间点的累积价格,T0T1 是对应的时间。

    • TWAP 计算公式为:

    TWAP=P1P0T1T0 \text{TWAP} = \frac{P1 - P0}{T1 - T0}
    • 具体案例如下

    image-20250126132306745

代码实现

  1. 累积价格更新
/**
 * @dev: 更新储备,并在每个区块的第一次调用时更新价格累加器
 * @param {uint} balance0:更新后tokenA的储备量
 * @param {uint} balance1:更新后tokenA的储备量
 * @param {uint112} _reserve0:当前tokenA的储备量
 * @param {uint112} _reserve1:当前tokenB的储备量
 */
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
    // 取时间戳的低 32 位
    uint32 blockTimestamp = uint32(block.timestamp % 2 ** 32);
    // 时间间隔
    uint32 timeElapsed = blockTimestamp - blockTimestampLast;
    if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
        // 永远不会溢出,+ overflow是理想的
        // 使用 32 位整数记录时间戳,允许时间戳在 2**32 秒后溢出
        // 这种设计是因为时间戳溢出不会影响累积价格的正确性,反而可以简化计算
        // priceCumulativeLast += ((_reserve1 * 2 ** 112 ) / _reserve0 ) * timeElapsed
        price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
        price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
    }
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    blockTimestampLast = blockTimestamp;
    emit Sync(reserve0, reserve1);
}
  • 解释:
    • timeElapsed 是自上次更新以来经过的时间。
    • price0CumulativeLastprice1CumulativeLast 是累积价格。
    • 累积价格更新基于当前储备量和时间。
  1. 计算 TWAP

要计算某一时间段内的 TWAP,需要获取两个时间点的累积价格:

function getTWAP(uint32 startTime, uint32 endTime) external view returns (uint price0Average, uint price1Average) {
    // 获取开始和结束时的累积价格
    uint price0CumulativeStart = getPrice0Cumulative(startTime);
    uint price1CumulativeStart = getPrice1Cumulative(startTime);
    uint price0CumulativeEnd = getPrice0Cumulative(endTime);
    uint price1CumulativeEnd = getPrice1Cumulative(endTime);

    // 计算时间差
    uint32 timeElapsed = endTime - startTime;

    // 计算 TWAP
    price0Average = (price0CumulativeEnd - price0CumulativeStart) / timeElapsed;
    price1Average = (price1CumulativeEnd - price1CumulativeStart) / timeElapsed;
}

使用场景

  • 价格预言机:提供去中心化的价格参考,减少操纵。
  • 风险管理:使用平均价格进行交易,减少波动风险。
  • 套利检测:识别市场异常,进行套利操作。

流动性

流动性份额

在 Uniswap V2 中,流动性池使用一种共享的「份额机制」来表示用户对池中总流动性的所有权。 主要概念:

  • 每个流动性提供者(Liquidity Provider, LP)在向池中存入代币时,会按比例获得池中总份额的一部分,称为 Shares

  • 池子的总值(L)会随着交易费或流动性增加而变化,每个份额对应的价值也会随之调整。

  • 提供流动性时:根据用户提供的代币量,按比例分配新增的 Shares。

  • 移除流动性时:用户销毁其持有的 Shares,按比例提取池中的代币。

  • 收益分配:池中的交易手续费(0.3%)会转入流动性池,由所有流动性提供者按 Shares 比例共享。

价值定义

流动性池的价值(L)是用来衡量池子的整体「流动性贡献」的一个抽象指标。

这里将价值的计算与流动性池的两种代币储备量(xy)绑定,定义为一个函数 f(x, y)

L=f(x,y)L = f(x, y)

函数 f(x, y) 的意义

  • 输入x 是代币 A 的储备量,y 是代币 B 的储备量。
  • 输出:池子的总价值 LL
  • 函数 f(x, y) 的形式可以不同,具体取决于我们如何衡量流动性池的总价值。

例如:

  1. 函数形式 1f(x,y)=xyf(x, y) = \sqrt{x \cdot y}
    • 基于代币储备的几何均值,体现了储备的均衡性。
  2. 函数形式 2f(x,y)=2xf(x, y) = 2x
    • 仅用代币 A 的储备衡量价值(忽略 B)。
  3. 函数形式 3f(x,y)=2yf(x, y) = 2y
    • 仅用代币 B 的储备衡量价值(忽略 A)。

image-20250111194244987

不同价值定义的影响

(1) 几何均值定义:f(x,y)=xyf(x, y) = \sqrt{x \cdot y}

  • 几何均值是 Uniswap 的默认逻辑,用于衡量池子两种代币的平衡程度。
  • 它的一个特点是:当两种代币的储备量接近时,池子的价值最大化。

例子:

  • 如果池子中有 x=100x = 100y=100y = 100,则: L=100100=100L = \sqrt{100 \cdot 100} = 100

(2) 线性定义:f(x,y)=2xf(x, y) = 2xf(x,y)=2yf(x, y) = 2y

  • 如果只用其中一种代币的储备来衡量价值(如 f(x,y)=2xf(x, y) = 2x),则池子的价值仅由一种代币决定。
  • 这种方法会导致流动性提供者向池中注入某一种代币时,无法正确反映池子的总价值。

例子:

  • 如果 x=100x = 100y=50y = 50
    • f(x,y)=2x=200f(x, y) = 2x = 200
    • f(x,y)=2y=100f(x, y) = 2y = 100
  • 这种定义方式不考虑两种代币的平衡性,因此不常用。

Shares 分配

新增流动性

流动性提供者添加资金到池子,现货价格必须保持不变

image-20250111193650556

新的 Shares 通过以下公式计算:

T=当前总 Shares 数量T = \text{当前总 Shares 数量} L0=增加前池子的总价值(Liquidity Value)L_0 = \text{增加前池子的总价值(Liquidity Value)} L1=增加后池子的总价值(Liquidity Value)L_1 = \text{增加后池子的总价值(Liquidity Value)}

总流动性增长比例

  • 新增的流动性价值为:ΔL=L1L0\Delta L = L_1 - L_0

  • 总流动性增长比例为:

    ΔLL0=L1L0L0\frac{\Delta L}{L_0} = \frac{L_1 - L_0}{L_0}

新增 Shares 数量

  • 假设当前池子的总 Shares 为 TT,新增 Shares ss 应与总 Shares 成比例:
s=ΔLL0T=L1L0L0Ts = \frac{\Delta L}{L_0} \cdot T = \frac{L_1 - L_0}{L_0} \cdot T

image-20250111192818219

解释:

  • L0L_0 是流动性池在用户添加代币之前的总价值。
  • L1L_1 是流动性池在用户添加代币之后的总价值。
  • 增加的流动性相对于总价值的增长比例(L1L0L0\frac{L_1 - L_0}{L_0})决定了新增的 Shares 的比例。
  • 即,新增的 Shares 反映了新增流动性相对于池子总价值的增长比例。

移除流动性

当流动性提供者移除流动性时,用户销毁部分 Shares,并按比例提取代币

  1. 移除的份额比例

    • 用户希望移除 ss Shares,其占总 Shares 的比例为: sT\frac{s}{T}
  2. 移除的代币价值

    • 移除的流动性总价值为:

      ΔL=sTL0\Delta L = \frac{s}{T} \cdot L_0
    • 用户根据份额比例提取代币 A 和 B:

      ΔA=sTreserveA\Delta A = \frac{s}{T} \cdot reserveA ΔB=sTreserveB\Delta B = \frac{s}{T} \cdot reserveB
  3. 剩余 Shares

    • 用户销毁 ss Shares 后,池子的总 Shares 减少为 TsT - s

手续费

Shares 与手续费的关系

  • 在 Uniswap V2 中,每笔交易会收取 0.3%0.3\% 的手续费,直接留在流动性池中。
  • 手续费增加了池子的总价值 LL,但总 Shares TT 不变。
  • 结果:每个 Share 的价值变大,流动性提供者的收益会随着交易量的增加而累积。

示例

假设池子中有:

  • reserveA=1000reserveA = 1000reserveB=2000reserveB = 2000
  • 总 Shares 为 T=1000T = 1000
  • 某笔交易产生了 33 单位的代币 A 和 66 单位的代币 B 的手续费。

交易后:

  • 新的储备量:reserveA=1003reserveA = 1003reserveB=2006reserveB = 2006

  • 总 Shares 仍为 T=1000T = 1000

  • 每个 Share 的价值变为:

    Share Value=reserveA+reserveBT=1003+20061000=3.009\text{Share Value} = \frac{reserveA + reserveB}{T} = \frac{1003 + 2006}{1000} = 3.009
  • 提供者未进行额外操作,收益自动累积。

案例分析

示例场景的详细解释:

场景 1:新增流动性

假设:

  • 当前池子有 10001000 USDC,流动性总 Shares 为 10001000
  • 新的流动性提供者想要增加 110110 USDC。

计算:

  • L0=1000L_0 = 1000
  • L1=1100L_1 = 1100
  • 总 Shares T=1000T = 1000

根据公式:

s=L1L0L0T=1100100010001000=110s = \frac{L_1 - L_0}{L_0} \cdot T = \frac{1100 - 1000}{1000} \cdot 1000 = 110

因此,新用户将获得 110110 Shares。

场景 2:移除流动性

假设:

  • 总 Shares 为 11001100(之前 10001000 + 新增的 110110)。
  • 用户想移除 100100 Shares。

计算:

  • 总池子价值 L0=1210L_0 = 1210 USDC。

  • 用户持有的 Shares 比例为:

    sT=1001100\frac{s}{T} = \frac{100}{1100}

移除的流动性值为:

L0L1=sTL0=10011001210110L_0 - L_1 = \frac{s}{T} \cdot L_0 = \frac{100}{1100} \cdot 1210 \approx 110

因此,用户移除 100100 Shares 后,从池中取走了 110110 USDC。

image-20250111193419131

份额减少带来的影响

当用户移除 Shares 时,池子的总 Shares 减少,但剩余的流动性分配比例保持不变。这确保了:

  • 移除流动性不会影响其他流动性提供者的权益。
  • 池子的总价值随着 Shares 减少按比例变化。

逻辑实现

添加流动性

  1. Router 合约处理用户输入
    • 检查或创建交易对。
    • 调整代币比例,确保符合池子价格。
    • 转移用户的代币到 Pair 合约。
  2. Pair 合约处理流动性逻辑
    • 计算流动性份额。
    • 如果首次添加流动性,锁定最小流动性。
    • 分发 LP 代币给用户。
  3. 更新池子状态
    • 更新储备量(reserve0reserve1)。
    • 如果启用了手续费累积,更新 k 值并铸造手续费 LP 代币。

image-20250111194655161

详细步骤如下

1. 用户调用入口

用户通过 Router 合约addLiquidity 函数发起添加流动性的操作。用户需要提供以下输入参数:

  • tokenAtokenB:两种代币的地址。
  • amountADesiredamountBDesired:希望存入的两种代币的数量。
  • amountAMinamountBMin:允许的最小存入数量(用于滑点保护)。
  • to:接收 LP 代币的地址。
  • deadline:操作的最后期限。

Router 合约会检查交易对是否存在,并通过 _addLiquidity 调整代币比例,然后将代币转移到流动性池(Pair 合约)。

2. 创建或检查交易对

在 Router 的 addLiquidity 函数中,首先检查指定的交易对是否已经存在:

  • 如果交易对不存在,调用 Factory 合约createPair 函数,创建一个新的交易对(Pair 合约)。
  • 如果交易对已存在,直接跳过创建。

3. 调整代币比例

当交易对存在时,Router 会调用 _addLiquidity 函数,调整用户输入的代币数量,使其符合池子的价格比例。这个过程需要考虑以下两种情况:

  • 首次添加流动性:池子没有储备量,直接使用用户输入的数量。

如果池子是空的(reserveA == 0 && reserveB == 0),直接使用用户提供的 amountADesiredamountBDesired。这种情况下,不需要调用 quote 函数调整比例。首次添加流动性时,用户输入的代币数量确定了池子的初始价格比例。

  • 池子已有储备:根据池子的储备比例调整用户输入的代币数量。

如果池子已有储备量(reserveA > 0 && reserveB > 0),Router 使用 quote 函数计算调整后的代币数量。

调整逻辑

  • 根据池子的储备比例计算与 amountADesired 对应的最优 amountBOptimal

    amountBOptimal=amountADesiredreserveBreserveAamountBOptimal = \frac{amountADesired \cdot reserveB}{reserveA}
  • 如果 amountBOptimal <= amountBDesired,说明提供的 B 足够多,可以直接按比例调整:

    • amountA = amountADesired
    • amountB = amountBOptimal
  • 否则,反向调整:

    • 计算与 amountBDesired 对应的最优 amountAOptimal

      amountAOptimal=amountBDesiredreserveAreserveBamountAOptimal = \frac{amountBDesired \cdot reserveA}{reserveB}
    • 使用较小的数量作为最终调整值。

4. 转移代币到流动性池

调整好代币数量后,Router 使用 safeTransferFrom 将用户的代币转移到 Pair 合约。这个操作直接调用 ERC-20 的 transferFrom 函数。

TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);

关键点:代币转移完成后,代币余额将记录在 Pair 合约中,用于后续的流动性计算。

5. 计算并铸造 LP 代币

在 Pair 合约中,通过 mint 函数计算新增的流动性份额,并分发 LP 代币。首次流动性和后续流动性的计算逻辑不同。

首次流动性添加

首次添加流动性时,流动性份额(liquidity)按以下公式计算:

liquidity=amount0amount1MINIMUM_LIQUIDITYliquidity = \sqrt{amount0 \cdot amount1} - MINIMUM\_LIQUIDITY

其中:

  • MINIMUM_LIQUIDITY 是一个固定值(通常为 1000),会被永久锁定在零地址,用于防止极端情况(如完全移除流动性)。
if (totalSupply == 0) {
    liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
    _mint(address(0), MINIMUM_LIQUIDITY); // 永久锁定
}

后续流动性添加

如果池子已有储备量,流动性份额按比例分配:

liquidity=min(amount0totalSupplyreserve0,amount1totalSupplyreserve1)liquidity = \min\left(\frac{amount0 \cdot totalSupply}{reserve0}, \frac{amount1 \cdot totalSupply}{reserve1}\right)
liquidity = Math.min(
    amount0 * totalSupply / _reserve0,
    amount1 * totalSupply / _reserve1
);

关键点:后续流动性添加时,用户获得的流动性份额与其提供的代币数量成比例。

  1. 更新储备量和 k 值

完成流动性分配后,Pair 合约调用 _update 函数更新储备量和时间加权平均价格(TWAP)。

function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    blockTimestampLast = uint32(block.timestamp % 2**32);
}

此外,如果启用了手续费累积(feeOn),会调用 _mintFee 更新 kLastkLast 并铸造 LP 代币给手续费接收地址。

移除流动性

Router 合约负责用户交互,检查输入参数、调用 Pair 合约进行流动性操作。

Pair 合约处理核心逻辑,包括销毁 LP 代币、计算提取的代币数量、更新储备量。

用户接收到的代币份额与其 LP 代币占比一致,且受滑点保护机制保证。

image-20250113205635291

详细步骤如下

1. 用户调用入口

用户通过调用 removeLiquidity 函数启动移除流动性的操作,需提供以下输入参数:

  • tokenA 和 tokenB:两种代币的地址。
  • liquidity:用户希望移除的流动性(LP 代币的数量)。
  • amountAMin 和 amountBMin:用户期望至少接收到的两种代币数量,用于滑点保护。
  • to:接收代币的目标地址。
  • deadline:操作的最后期限。

Router 合约的主要职责是验证用户的输入参数并将任务分发给 Pair 合约。会做如下检查:

  • deadline:确保交易在指定时间内完成。
  • 获取 Pair 合约地址,确保用户移除流动性的代币对存在。

2. 将 LP 代币转移到 Pair 合约

用户通过 ERC-20 的 transferFrom 方法将指定数量的 LP 代币发送到 Pair 合约地址。

在移除流动性时,Router 合约会执行:

IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity);

这一步将用户的 LP 代币锁定在 Pair 合约中,为后续的销毁和提取代币份额做准备。

3. Pair 合约的 burn 操作

用户的 LP 代币被发送到 Pair 合约后,Pair 合约调用 burn 函数进行处理。burn 的核心逻辑包括:

3.1 计算用户提取份额

根据用户销毁的 LP 代币数量与池子总供应量的比例,计算用户可以提取的两种代币的数量:

  • 用户占比:

    占比=用户提交的 LP 数量LP 总供应量\text{占比} = \frac{\text{用户提交的 LP 数量}}{\text{LP 总供应量}}
  • 提取的两种代币数量:

    提取的 TokenA 数量=占比×池中 TokenA 储备量\text{提取的 TokenA 数量} = \text{占比} \times \text{池中 TokenA 储备量} 提取的 TokenB 数量=占比×池中 TokenB 储备量\text{提取的 TokenB 数量} = \text{占比} \times \text{池中 TokenB 储备量}

3.2 销毁用户的 LP 代币

Pair 合约调用内部的 _burn 函数销毁用户提交的 LP 代币,减少流动性池的 LP 供应量。

3.3 更新储备量

移除流动性后,池子的储备量需要减少对应数量的代币:

  • 新的储备量:

    新的 TokenA 储备量=原储备量提取的 TokenA 数量\text{新的 TokenA 储备量} = \text{原储备量} - \text{提取的 TokenA 数量} 新的 TokenB 储备量=原储备量提取的 TokenB 数量\text{新的 TokenB 储备量} = \text{原储备量} - \text{提取的 TokenB 数量}

3.4 代币转移到用户地址

Pair 合约将计算出的两种代币数量转移到用户指定的地址(to 参数)。

3.5 更新储备量和 k 值

完成流动性分配后,Pair 合约调用 _update 函数更新储备量和时间加权平均价格(TWAP)。

4. Router 合约的滑点保护

在 Pair 合约完成 burn 操作后,Router 合约会检查用户实际接收到的代币数量是否满足 amountAMinamountBMin 的要求:

require(amountA >= amountAMin, "INSUFFICIENT_A_AMOUNT");
require(amountB >= amountBMin, "INSUFFICIENT_B_AMOUNT");

如果任意一种代币的实际接收数量小于用户的最低要求,交易会回滚,防止用户因为滑点或价格变化而受到损失。

作者:加密鲸拓

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