参考资料:
价格滑点
现货价格
现货价格(Spot Price)是指流动性池中当前的代币兑换比率,反映了代币对的市场价格。它是一个动态值,随着流动性池中代币余额的变化实时调整。
在 Uniswap 中,现货价格的定义为:
- 是池中代币 的数量;
- 是池中代币 的数量。
例如,如果池中 DAI, ETH,则现货价格为:
现货价格的意义:
- 如果代币 是 DAI,代币 是 ETH,那么现货价格 反映了 1 单位 DAI 兑换多少单位 ETH。
- 现货价格直接反映了 AMM 池子的状态,但并不等同于实际交易的执行价格。
执行价格
执行价格(Execution Price)是用户在实际交易中得到的代币兑换比率,也称为 交易价格,定义为:
相比现货价格,执行价格会受到交易规模和滑点的影响。执行价格由池子的状态变化决定,公式为:
滑点
滑点(Slippage)是指 执行价格 与 现货价格 之间的差异,通常以百分比形式表示:
滑点的大小取决于:
- 交易规模:交易量越大,对池子的价格影响越显著,滑点越大。
- 流动性:流动性越高,价格曲线越平滑,同样的交易量会导致更小的滑点。
滑点的原因
-
交易曲线斜率的变化:
- 现货价格对应的是交易曲线的局部斜率,而执行价格是实际交易产生的代币比率。由于池子采用恒定乘积公式 ,随着交易量的增加,曲线变得更陡,滑点也随之增加。
-
市场流动性有限:
- 滑点本质上是流动性不足的体现。当池子中的 和 较小时,即池子规模较小时,较大的交易会显著改变池子的代币余额,从而导致更高的滑点。
-
连续多笔交易的影响(市场移动):
-
下图中显示了两个交易者(Alice 和 Bob)的连续交易场景:
-
Alice 提交交易,提交 DAI,获得 ETH;
-
Bob 紧接着提交交易,但由于池子的状态已被 Alice 的交易改变,Bob 的交易价格进一步变差,导致滑点更高。
-
-
改进滑点的措施
- 增加流动性
流动性越高,交易曲线越平滑,单笔交易对池子的影响越小,从而减少滑点。例如,当池子从 DAI / ETH 增加到 DAI / ETH 时,同样的 DAI 交易会产生更小的滑点。
- 选择流动性更大的池子
如果某个代币交易对有多个流动性池,选择流动性更大的池子进行交易,可以有效减少滑点。
- 设置滑点保护
设置滑点保护上限(Slippage Tolerance),一旦滑点超出用户设定的范围,交易将被取消。
时间加权价格
时间加权平均价格(TWAP)是 Uniswap V2 中的一项重要功能,用于提供更稳定和抗操纵的价格指标。TWAP 通过在一段时间内计算价格平均值,减少了短期价格波动和操纵的影响。
原理
Uniswap V2 中的每个交易对都维护一个累积价格变量,该变量随着时间的推移不断更新。通过使用这些累积价格,可以计算出任意时间段内的平均价格。
-
累积价格:
- 每个区块结束时,Uniswap 会更新交易对的累积价格。
- 累积价格是代币价格乘以时间的总和。
- 记录两个累积价格:一个针对
token0
,另一个针对token1
。
-
计算 TWAP:
- 选择两个时间点,分别获取这两个时间点的累积价格。
- 通过累积价格的差值除以时间差,得到这段时间内的平均价格。
使用场景
- 价格预言机:TWAP 可以用于构建去中心化的价格预言机,提供抗操纵的价格参考。
- 风险管理:通过平均价格进行交易,减少价格剧烈波动带来的风险。
- 套利检测:识别市场上短期价格异常,进行套利操作。
实现细节
-
累积价格更新:
- 每个交易对在每个区块结束时更新累积价格。
- 累积价格是价格乘以时间的总和。
- Uniswap 维护两个累积价格:
price0CumulativeLast
和price1CumulativeLast
。
-
计算公式:
-
假设
P0
和P1
是两个时间点的累积价格,T0
和T1
是对应的时间。 -
TWAP 计算公式为:
- 具体案例如下
-
代码实现
- 累积价格更新
/** * @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
是自上次更新以来经过的时间。price0CumulativeLast
和price1CumulativeLast
是累积价格。- 累积价格更新基于当前储备量和时间。
- 计算 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
)是用来衡量池子的整体「流动性贡献」的一个抽象指标。
这里将价值的计算与流动性池的两种代币储备量(x
和 y
)绑定,定义为一个函数 f(x, y)
:
函数 f(x, y)
的意义
- 输入:
x
是代币 A 的储备量,y
是代币 B 的储备量。 - 输出:池子的总价值 。
- 函数
f(x, y)
的形式可以不同,具体取决于我们如何衡量流动性池的总价值。
例如:
- 函数形式 1:
- 基于代币储备的几何均值,体现了储备的均衡性。
- 函数形式 2:
- 仅用代币 A 的储备衡量价值(忽略 B)。
- 函数形式 3:
- 仅用代币 B 的储备衡量价值(忽略 A)。
不同价值定义的影响
(1) 几何均值定义:
- 几何均值是 Uniswap 的默认逻辑,用于衡量池子两种代币的平衡程度。
- 它的一个特点是:当两种代币的储备量接近时,池子的价值最大化。
例子:
- 如果池子中有 和 ,则:
(2) 线性定义: 或
- 如果只用其中一种代币的储备来衡量价值(如 ),则池子的价值仅由一种代币决定。
- 这种方法会导致流动性提供者向池中注入某一种代币时,无法正确反映池子的总价值。
例子:
- 如果 且 :
- 或
- 这种定义方式不考虑两种代币的平衡性,因此不常用。
Shares 分配
新增流动性
流动性提供者添加资金到池子,现货价格必须保持不变
新的 Shares 通过以下公式计算:
总流动性增长比例
-
新增的流动性价值为:。
-
总流动性增长比例为:
新增 Shares 数量
- 假设当前池子的总 Shares 为 ,新增 Shares 应与总 Shares 成比例:
解释:
- 是流动性池在用户添加代币之前的总价值。
- 是流动性池在用户添加代币之后的总价值。
- 增加的流动性相对于总价值的增长比例()决定了新增的 Shares 的比例。
- 即,新增的 Shares 反映了新增流动性相对于池子总价值的增长比例。
移除流动性
当流动性提供者移除流动性时,用户销毁部分 Shares,并按比例提取代币。
-
移除的份额比例
- 用户希望移除 Shares,其占总 Shares 的比例为:
-
移除的代币价值
-
移除的流动性总价值为:
-
用户根据份额比例提取代币 A 和 B:
-
-
剩余 Shares
- 用户销毁 Shares 后,池子的总 Shares 减少为 。
手续费
Shares 与手续费的关系
- 在 Uniswap V2 中,每笔交易会收取 的手续费,直接留在流动性池中。
- 手续费增加了池子的总价值 ,但总 Shares 不变。
- 结果:每个 Share 的价值变大,流动性提供者的收益会随着交易量的增加而累积。
示例
假设池子中有:
- 和 ;
- 总 Shares 为 ;
- 某笔交易产生了 单位的代币 A 和 单位的代币 B 的手续费。
交易后:
-
新的储备量: 和 ;
-
总 Shares 仍为 ;
-
每个 Share 的价值变为:
-
提供者未进行额外操作,收益自动累积。
案例分析
示例场景的详细解释:
场景 1:新增流动性
假设:
- 当前池子有 USDC,流动性总 Shares 为 。
- 新的流动性提供者想要增加 USDC。
计算:
- 总 Shares
根据公式:
因此,新用户将获得 Shares。
场景 2:移除流动性
假设:
- 总 Shares 为 (之前 + 新增的 )。
- 用户想移除 Shares。
计算:
-
总池子价值 USDC。
-
用户持有的 Shares 比例为:
移除的流动性值为:
因此,用户移除 Shares 后,从池中取走了 USDC。
份额减少带来的影响
当用户移除 Shares 时,池子的总 Shares 减少,但剩余的流动性分配比例保持不变。这确保了:
- 移除流动性不会影响其他流动性提供者的权益。
- 池子的总价值随着 Shares 减少按比例变化。
逻辑实现
添加流动性
- Router 合约处理用户输入:
- 检查或创建交易对。
- 调整代币比例,确保符合池子价格。
- 转移用户的代币到 Pair 合约。
- Pair 合约处理流动性逻辑:
- 计算流动性份额。
- 如果首次添加流动性,锁定最小流动性。
- 分发 LP 代币给用户。
- 更新池子状态:
- 更新储备量(
reserve0
和reserve1
)。- 如果启用了手续费累积,更新 k 值并铸造手续费 LP 代币。
详细步骤如下
1. 用户调用入口
用户通过 Router 合约 的 addLiquidity
函数发起添加流动性的操作。用户需要提供以下输入参数:
tokenA
和tokenB
:两种代币的地址。amountADesired
和amountBDesired
:希望存入的两种代币的数量。amountAMin
和amountBMin
:允许的最小存入数量(用于滑点保护)。to
:接收 LP 代币的地址。deadline
:操作的最后期限。
Router 合约会检查交易对是否存在,并通过 _addLiquidity
调整代币比例,然后将代币转移到流动性池(Pair 合约)。
2. 创建或检查交易对
在 Router 的 addLiquidity
函数中,首先检查指定的交易对是否已经存在:
- 如果交易对不存在,调用 Factory 合约 的
createPair
函数,创建一个新的交易对(Pair 合约)。 - 如果交易对已存在,直接跳过创建。
3. 调整代币比例
当交易对存在时,Router 会调用 _addLiquidity
函数,调整用户输入的代币数量,使其符合池子的价格比例。这个过程需要考虑以下两种情况:
- 首次添加流动性:池子没有储备量,直接使用用户输入的数量。
如果池子是空的(reserveA == 0 && reserveB == 0
),直接使用用户提供的 amountADesired
和 amountBDesired
。这种情况下,不需要调用 quote
函数调整比例。首次添加流动性时,用户输入的代币数量确定了池子的初始价格比例。
- 池子已有储备:根据池子的储备比例调整用户输入的代币数量。
如果池子已有储备量(reserveA > 0 && reserveB > 0
),Router 使用 quote
函数计算调整后的代币数量。
调整逻辑
-
根据池子的储备比例计算与
amountADesired
对应的最优amountBOptimal
: -
如果
amountBOptimal <= amountBDesired
,说明提供的 B 足够多,可以直接按比例调整:amountA = amountADesired
amountB = amountBOptimal
-
否则,反向调整:
-
计算与
amountBDesired
对应的最优amountAOptimal
: -
使用较小的数量作为最终调整值。
-
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
)按以下公式计算:
其中:
MINIMUM_LIQUIDITY
是一个固定值(通常为 1000),会被永久锁定在零地址,用于防止极端情况(如完全移除流动性)。
if (totalSupply == 0) { liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY; _mint(address(0), MINIMUM_LIQUIDITY); // 永久锁定 }
后续流动性添加
如果池子已有储备量,流动性份额按比例分配:
liquidity = Math.min( amount0 * totalSupply / _reserve0, amount1 * totalSupply / _reserve1 );
关键点:后续流动性添加时,用户获得的流动性份额与其提供的代币数量成比例。
- 更新储备量和 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
更新 并铸造 LP 代币给手续费接收地址。
移除流动性
Router 合约负责用户交互,检查输入参数、调用 Pair 合约进行流动性操作。
Pair 合约处理核心逻辑,包括销毁 LP 代币、计算提取的代币数量、更新储备量。
用户接收到的代币份额与其 LP 代币占比一致,且受滑点保护机制保证。
详细步骤如下
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 代币数量与池子总供应量的比例,计算用户可以提取的两种代币的数量:
-
用户占比:
-
提取的两种代币数量:
3.2 销毁用户的 LP 代币
Pair 合约调用内部的 _burn
函数销毁用户提交的 LP 代币,减少流动性池的 LP 供应量。
3.3 更新储备量
移除流动性后,池子的储备量需要减少对应数量的代币:
-
新的储备量:
3.4 代币转移到用户地址
Pair 合约将计算出的两种代币数量转移到用户指定的地址(to
参数)。
3.5 更新储备量和 k 值
完成流动性分配后,Pair 合约调用 _update
函数更新储备量和时间加权平均价格(TWAP)。
4. Router 合约的滑点保护
在 Pair 合约完成 burn
操作后,Router 合约会检查用户实际接收到的代币数量是否满足 amountAMin
和 amountBMin
的要求:
require(amountA >= amountAMin, "INSUFFICIENT_A_AMOUNT"); require(amountB >= amountBMin, "INSUFFICIENT_B_AMOUNT");
如果任意一种代币的实际接收数量小于用户的最低要求,交易会回滚,防止用户因为滑点或价格变化而受到损失。
作者:加密鲸拓
版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!