Uniswap-V3的流动性挖矿原理介绍
这里是基于阅读参考文章后的笔记记录,中间增加了一些自己结合源码+部分细节的推敲
为了能够提升协议的资金利用率,Uniswap-V3在流动性管理这块允许用户在提供流动性的时候指定特定的价格区间,这一特性同时也导致了基于用户头寸衍生的一些业务场景的计算模型无法复用V2时代的计算模型。
V2时代的挖矿收益计算模型
我们先回顾一下V2时代的流动性挖矿收益计算模型,
我们先取
- $R$ 为每秒钟的发放收益,
- $l(t)$ 是在 $t$ 时刻,某用户提供的流动性的总价值
- $L(t)$ 是在 $t$ 时刻,所质押的流动性的总价值
那么该用户在 $t$ 时刻所收到的质押收益是 $R \cdot \frac{l(t)}{L(t)}$
用户在$t_0$ 到 $t_1$ 的这段时间,他所收到的质押收益是
$$ \sum_{t=t_0}^{t1} {R \cdot \frac{l(t)}{L(t)}} $$
这公式在链下的环境去计算很简单,但是如果放到链上,就会很耗gas,因此需要对其进行优化
如果用户全程都只质押了 $l$ 这么多的资产,上面的公式可以提炼成
$$ R \cdot l \cdot \sum_{t=t_0}^{t1} {\frac{1}{L(t)}} $$
那么用户在 $t_0$ 到 $t_1$ 这段时间的收益计算公式转换成:
$$ R \cdot l \cdot (\sum_{t=0}^{t0} {\frac{1}{L(t)}}-\sum_{t=0}^{t1} {\frac{1}{L(t)}}) $$
我们令到
$$ S_l(t_i)=\sum_{t=0}^{t_i} {\frac{1}{L(t)}} $$
每当有用户触发质押和提取的操作的时候,触发更新一下对应的 $S_l$ 的值,注意,这里涉及到两个变量的维护,
最终变成质押收益计算公式,从每次计算都要循环迭代加乘触计算变成每次只要做一次减法计算
$$ R(l,S_l(t_1),S_l(t_0)) = R \cdot l \cdot (S_l(t_1) - S_l(t_0)) $$
我们取用户在 $t_1$ 增加了$\Delta{L}$ 的流动性到池子里面
先更新
$$
S_l(t_1)=\sum_{t=0}^{t_0} {\frac{1}{L(t)}} + \frac{t_1-t_0}{L(t_0)}
$$
然后再更新
$$ L(t_1) = L(t_0) + \Delta{L} $$
当用户在 $t_2$ 的时刻提取出流动性的时候
先更新
$$
S_l(t_2)=\sum_{t=0}^{t_1} {\frac{1}{L(t)}} + \frac{t_2-t_1}{L(t_1)}
$$
然后再更新
$$ L(t_2) = L(t_1) - \Delta{L} $$
此时用户的收益情况是
$$
R \cdot l \cdot \frac{t_2-t_1}{L(t_1)}
$$
回到V3的挖矿模型中
既然用户在提供流动性的时候就指定了只在价格区间提供流动性,那么影响用户的质押收益的因子就不仅仅跟时间有关,还跟资产的价格有关,如果资产的价格超过或低于对应价格区间,那么用户就不再享有对应的收益
我们这里取用户在提供流动性的时候,对应的价格范围是
$$ i_{lower} \le i_c \le i_{upper} $$
$$ R \cdot l \cdot \sum_{t=t_1}^{t_2} \left{\begin{aligned}\frac{1}{L(t)} && i_{lower} \le i_c \le i_{upper} \ 0 && i_{upper} \le i_c \ 0 && i_c \le i_{lower} \end{aligned}\right. $$
可以整合成
$$ R \cdot l \cdot ((\sum_{t=t_1}^{t_2} {\frac{1}{L(t)}})-(\sum_{t=t_1}^{t_2}\left{\begin{aligned}\frac{1}{L(t)} && i_c \lt i_{lower}\ 0 && i_{c} \ge i_{lower} \end{aligned}\right.)-(\sum_{t=t_1}^{t_2}\left{\begin{aligned}\frac{1}{L(t)} && i_c \ge i_{upper}\ 0 && i_{c} \lt i_{upper} \end{aligned}\right.)) $$
在做这个计算的时候,会用到一些很tricky的技巧,这些技巧都参考甚至直接复用了Uniswap-V3计算手续费的方案
可以观察到,下面的方程其实Uniswap-V3里面有直接的函数可以直接调用求得某个时刻的数值
$$ ((\sum_{t=t_1}^{t_2} {\frac{1}{L(t)}})-(\sum_{t=t_1}^{t_2}\left{\begin{aligned}\frac{1}{L(t)} && i_c \lt i_{lower}\ 0 && i_{c} \ge i_{lower} \end{aligned}\right.)-(\sum_{t=t_1}^{t_2}\left{\begin{aligned}\frac{1}{L(t)} && i_c \ge i_{upper}\ 0 && i_{c} \lt i_{upper} \end{aligned}\right.)) $$
//https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L158
function snapshotCumulativesInside(int24 tickLower, int24 tickUpper)
external
view
override
noDelegateCall
returns (
int56 tickCumulativeInside,
uint160 secondsPerLiquidityInsideX128,
uint32 secondsInside
)
在用户stake也就是t1的时候跑一次,保留当前值,然后在计算收益的时候t2再跑一次,保留当前值,两次值相减即可得
我们来看看V3官方的质押挖矿代码里面计算收益的逻辑
// https://github.com/Uniswap/v3-staker/blob/main/contracts/UniswapV3Staker.sol#L280
function getRewardInfo(IncentiveKey memory key, uint256 tokenId)
external
view
override
returns (uint256 reward, uint160 secondsInsideX128)
{
bytes32 incentiveId = IncentiveId.compute(key);
(uint160 secondsPerLiquidityInsideInitialX128, uint128 liquidity) = stakes(tokenId, incentiveId);
require(liquidity > 0, 'UniswapV3Staker::getRewardInfo: stake does not exist');
Deposit memory deposit = deposits[tokenId];
Incentive memory incentive = incentives[incentiveId];
(, uint160 secondsPerLiquidityInsideX128, ) =
key.pool.snapshotCumulativesInside(deposit.tickLower, deposit.tickUpper);
(reward, secondsInsideX128) = RewardMath.computeRewardAmount(
incentive.totalRewardUnclaimed,
incentive.totalSecondsClaimedX128,
key.startTime,
key.endTime,
liquidity,
secondsPerLiquidityInsideInitialX128,
secondsPerLiquidityInsideX128,
block.timestamp
);
}
// https://github.com/Uniswap/v3-staker/blob/main/contracts/libraries/RewardMath.sol#L21
function computeRewardAmount(
uint256 totalRewardUnclaimed,
uint160 totalSecondsClaimedX128,
uint256 startTime,
uint256 endTime,
uint128 liquidity,
uint160 secondsPerLiquidityInsideInitialX128,
uint160 secondsPerLiquidityInsideX128,
uint256 currentTime
) internal pure returns (uint256 reward, uint160 secondsInsideX128) {
// this should never be called before the start time
assert(currentTime >= startTime);
// this operation is safe, as the difference cannot be greater than 1/stake.liquidity
// 收益计算时的值减去初始质押时的值
secondsInsideX128 = (secondsPerLiquidityInsideX128 - secondsPerLiquidityInsideInitialX128) * liquidity;
uint256 totalSecondsUnclaimedX128 =
((Math.max(endTime, currentTime) - startTime) << 128) - totalSecondsClaimedX128;
reward = FullMath.mulDiv(totalRewardUnclaimed, secondsInsideX128, totalSecondsUnclaimedX128);
}