首先看一下v4版本pool的state结构
/// @notice The state of a pool
/// @dev Note that feeGrowthGlobal can be artificially inflated
/// For pools with a single liquidity position, actors can donate to themselves to freely inflate feeGrowthGlobal
/// atomically donating and collecting fees in the same unlockCallback may make the inflated value more extreme
struct State {
Slot0 slot0;
uint256 feeGrowthGlobal0X128;
uint256 feeGrowthGlobal1X128;
uint128 liquidity;
mapping(int24 tick => TickInfo) ticks;
mapping(int16 wordPos => uint256) tickBitmap;
mapping(bytes32 positionKey => Position.State) positions;
}
slot0
Slot0
是一个紧凑的结构体,使用 bytes32
类型来存储多个字段的数据。通过这种方式,可以在以太坊智能合约中节省存储成本,因为存储多个字段在一个存储槽中比单独存储每个字段更节省 gas。
type Slot0 is bytes32;
using Slot0Library for Slot0 global;
/// @notice Library for getting and setting values in the Slot0 type
library Slot0Library {
uint160 internal constant MASK_160_BITS = 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
uint24 internal constant MASK_24_BITS = 0xFFFFFF;
uint8 internal constant TICK_OFFSET = 160;
uint8 internal constant PROTOCOL_FEE_OFFSET = 184;
uint8 internal constant LP_FEE_OFFSET = 208;
// #### GETTERS ####
function sqrtPriceX96(Slot0 _packed) internal pure returns (uint160 _sqrtPriceX96) {
assembly ("memory-safe") {
_sqrtPriceX96 := and(MASK_160_BITS, _packed)
}
}
function tick(Slot0 _packed) internal pure returns (int24 _tick) {
assembly ("memory-safe") {
_tick := signextend(2, shr(TICK_OFFSET, _packed))
}
}
function protocolFee(Slot0 _packed) internal pure returns (uint24 _protocolFee) {
assembly ("memory-safe") {
_protocolFee := and(MASK_24_BITS, shr(PROTOCOL_FEE_OFFSET, _packed))
}
}
function lpFee(Slot0 _packed) internal pure returns (uint24 _lpFee) {
assembly ("memory-safe") {
_lpFee := and(MASK_24_BITS, shr(LP_FEE_OFFSET, _packed))
}
}
// #### SETTERS ####
function setSqrtPriceX96(Slot0 _packed, uint160 _sqrtPriceX96) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result := or(and(not(MASK_160_BITS), _packed), and(MASK_160_BITS, _sqrtPriceX96))
}
}
function setTick(Slot0 _packed, int24 _tick) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result := or(and(not(shl(TICK_OFFSET, MASK_24_BITS)), _packed), shl(TICK_OFFSET, and(MASK_24_BITS, _tick)))
}
}
function setProtocolFee(Slot0 _packed, uint24 _protocolFee) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result :=
or(
and(not(shl(PROTOCOL_FEE_OFFSET, MASK_24_BITS)), _packed),
shl(PROTOCOL_FEE_OFFSET, and(MASK_24_BITS, _protocolFee))
)
}
}
function setLpFee(Slot0 _packed, uint24 _lpFee) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result :=
or(and(not(shl(LP_FEE_OFFSET, MASK_24_BITS)), _packed), shl(LP_FEE_OFFSET, and(MASK_24_BITS, _lpFee)))
}
}
}
type Slot0 is bytes32; 其中byte32代表32个字节,每个字节8位,总共256位,其数据的布局如下
(从最低有效位到最高有效位):
- 160 bits:
sqrtPriceX96
- 当前价格的平方根,编码为 Q96 格式。 - 24 bits:
tick
- 当前的 tick 值。 - 12 bits:
protocolFee
(方向 0->1) - 协议费用的上 12 位。 - 12 bits:
protocolFee
(方向 1->0) - 协议费用的下 12 位。 - 24 bits:
lpFee
- 当前流动性提供者的费用。 - 24 bits: 空白(未使用)。
结合Slot0Library 可以对slot0的每一部分作存取。
我们一点点看:
sqrtPriceX96
uint160 internal constant MASK_160_BITS = 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
function sqrtPriceX96(Slot0 _packed) internal pure returns (uint160 _sqrtPriceX96) {
assembly ("memory-safe") {
_sqrtPriceX96 := and(MASK_160_BITS, _packed)
}
}
function setSqrtPriceX96(Slot0 _packed, uint160 _sqrtPriceX96) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result := or(and(not(MASK_160_BITS), _packed), and(MASK_160_BITS, _sqrtPriceX96))
}
}
MASK_160_BITS
: 用于提取或设置sqrtPriceX96
的掩码。-
sqrtPriceX96(Slot0 _packed)
:提取sqrtPriceX96
字段,使用掩码MASK_160_BITS
和按位与操作。 -
setSqrtPriceX96(Slot0 _packed, uint160 _sqrtPriceX96)
:设置sqrtPriceX96
字段。使用按位与和按位或操作更新字段。(其操作分为两个步骤,第一步把前160位置为0,第二步把前160位设为_sqrtPriceX96,其余的位数和原来保持一致)
tick
uint24 internal constant MASK_24_BITS = 0xFFFFFF;
uint8 internal constant TICK_OFFSET = 160;
function tick(Slot0 _packed) internal pure returns (int24 _tick) {
assembly ("memory-safe") {
_tick := signextend(2, shr(TICK_OFFSET, _packed))
}
}
function setTick(Slot0 _packed, int24 _tick) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result := or(and(not(shl(TICK_OFFSET, MASK_24_BITS)), _packed), shl(TICK_OFFSET, and(MASK_24_BITS, _tick)))
}
}
先看tick方法其作用是从slot0中提取出tick的部分。首先需要了解一下signextend函数。
signextend(byteIndex, value)
byteIndex
: 指定需要扩展的符号位的字节索引(从低位开始)。例如:0
表示扩展第 1 个字节(8 位)。1
表示扩展前 2 个字节(16 位)。2
表示扩展前 3 个字节(24 位)。
value
: 需要进行符号扩展的值。
打个比方
signextend(0, -29)=signextend(1, 10011101)
拓展低位第一个字节,10011101最高位为1代表是一个负数,扩展后:1111...1111_1001_1101,一共256位,
EVM 中的整数默认是 256 位宽(int256
或 uint256
),所以 signextend
的目标总是扩展到 256 位。这是 EVM 的固定规则,开发者无需显式指定目标位宽。
故而_tick := signextend(2, shr(TICK_OFFSET, _packed))的处理步骤为:
1. shr(TICK_OFFSET, _packed)
:先将 _packed
右移 TICK_OFFSET
位(160 位)
2. signextend(2, ...)
提取出低 24 位整数扩展为 256 位,同时保留符号。
- 如果
tick
的最高有效位(第 23 位)是1
,表示这是一个负数,扩展时会在高位填充1
(符号位)。 - 如果最高有效位是
0
,表示这是一个正数,扩展时会在高位填充0
。
setTick的处理方式和setSqrtPriceX96类似,这里不再赘述!
protocolFee
接下来从184位往后的24位是protocolFee(协议费用),其分为两个12位的部分
- 高 12 位: 表示从流动性池的输入代币中收取的费用比例(1->0)。
- 低 12 位: 表示从流动性池的输出代币中收取的费用比例(0->1)。
其最大值是 1000
,也就是 0.1% 的费用,之前的文章(uniswap v4 合约解析1 pool初始化)可以看到protocolFee是唯一一个在pool初始化时没有被赋值的参数,这个参数由uniswap官方维护,pool的创建者无法设置这个参数,protocolFee也是uniswap协议的主要收入来源之一,大多数情况下正反向交易的费率是一样的,但协议有能力对特定交易对、特定方向做差异化调整。比如某些交易对可能存在单边流动性压力(某一方向更容易被攻击或更频繁被用作套利),可以通过调整该方向的协议费来应对。设计上预留双向费率,可以为未来的治理、动态费率、特殊激励等功能提供基础。
uint24 internal constant MASK_24_BITS = 0xFFFFFF;
uint8 internal constant PROTOCOL_FEE_OFFSET = 184;
function protocolFee(Slot0 _packed) internal pure returns (uint24 _protocolFee) {
assembly ("memory-safe") {
_protocolFee := and(MASK_24_BITS, shr(PROTOCOL_FEE_OFFSET, _packed))
}
}
function setProtocolFee(Slot0 _packed, uint24 _protocolFee) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result :=
or(
and(not(shl(PROTOCOL_FEE_OFFSET, MASK_24_BITS)), _packed),
shl(PROTOCOL_FEE_OFFSET, and(MASK_24_BITS, _protocolFee))
)
}
}
上面的存取代码,和前面类似,不多做解释了!
lpFee
最后一部分,从208位往后的24位是lpFee
,也就是流动性提供者可以获取的交易手续费。
- 如果
lpFee = 3000
,表示 LP 收取交易金额的 0.3%。 - 如果
lpFee = 100
,表示 LP 收取交易金额的 0.01%
uint8 internal constant LP_FEE_OFFSET = 208;
function lpFee(Slot0 _packed) internal pure returns (uint24 _lpFee) {
assembly ("memory-safe") {
_lpFee := and(MASK_24_BITS, shr(LP_FEE_OFFSET, _packed))
}
}
function setLpFee(Slot0 _packed, uint24 _lpFee) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result :=
or(and(not(shl(LP_FEE_OFFSET, MASK_24_BITS)), _packed), shl(LP_FEE_OFFSET, and(MASK_24_BITS, _lpFee)))
}
}
存取代码如上,需要注意的是在这个参数上,v3和v4版本是存在很大区别的:
特性 | Uniswap V3 | Uniswap V4 |
---|---|---|
费率类型 | 固定费率 | 动态费率 |
费率选项 | 0.05%、0.3%、1% | 可自定义,根据逻辑动态调整 |
灵活性 | 池创建时决定,无法更改 | 支持动态调整,灵活性更高 |
实现方式 | 固定存储在池的状态中 | 通过逻辑或 Hooks 动态实现 |
最后来个图解,可以更加直观的了解solt0的结构
| 24 bits | 24 bits | 12 bits | 12 bits | 24 bits | 160 bits |
| 空余位 | lpFee | protocolFee 1->0 | protocolFee 0->1 | tick | sqrtPriceX96 |
|-------------|-------------|------------------------|-------------------------|-------------|-------------------------|
| 232-255 | 208-231 | 196-207 | 184-195 | 160-183 | 0-159 |
tick状态存储
首先看一下tick的存储结构
struct Info {
// 所有引用这个tick的position的流动性总和
uint128 liquidityGross;
//当tick被从左到右(从右到左)穿过时,流动性应该增加或减少的数值
int128 liquidityNet;
。。。
}
比方说有两个 position 中的流动性相等,例如 L = 500,并且这两个 position 同时引用了一个 tick,其中一个为 lower tick ,另一个为 upper tick,那么对于这个 tick,它的 liquidityNet = +500-500=0。而liquidityGross=500+500=1000
当价格变动导致 tickcurrent 越过一个 position 的 lower/upper tick 时,我们需要根据 tick 中记录的值来更新当前价格所对应的总体流动性。假设 position 的流动性值为 ΔL,会有以下四种情况:
价格上涨,从左到右穿过一个 lower tick:liquidityNet = liquidityNet + ΔL;
价格上涨,从左到右穿过一个 upper tick:liquidityNet = liquidityNet - ΔL;
价格下降,从右到左穿过一个 upper tick:liquidityNet = liquidityNet + ΔL;
价格下降,从右到左穿过一个 lower tick:liquidityNet = liquidityNet - ΔL;
uniswap v3/V4 版本对价格的计算为了减少开根号的的计算成本直接存储的是,并且使用Q64.94精度的定点数来保存。首先解释下这个Q64.94代表什么意思。
Q (number format)是一种指定二进制定点数的格式的方法。例如Q8.8表示的数字格式意味着这种格式中的定点数字整数部分有8位,小数数部分有8位。对于Q64.94而言,其代表的数值范围是0 至 。
也就是说而
对应的
于是得出tickMax = 887272 为了做对应 tickMin = -887272
这就意味着v3/v4版本的智能合约需要管理887272*2个tick,达到了百万级,这个数量是不小的。
而实际上这么多的tick其中绝大部分是没有必要初始化的。合约代码中对这些tick做了二级管理。
mapping(int16 => uint256) public override tickBitmap;
function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) {
wordPos = int16(tick >> 8);
bitPos = uint8(tick % 256);
}
887272*2个tick合约中用int24来表示;int16(tick >> 8)代表取高16位,uint8(tick % 256)代表取低8位,在tickBitmap中高16位作为key,那么为什么用uint256作为value呢?剩下的低8位是2的8次方一共256个数。也就是说tickBitmap每一条记录需要管理256个tick状态,最高效的方法就是使用位图,把256个数转换成256个二进制数表示,也就是uint256,相应的位上为1代表当前的tick已经被初始化。
positions
mapping(bytes32 positionKey => Position.State) positions;
这个映射的作用是记录每个 LP(流动性提供者)在不同价格区间的头寸(Position)状态。
positionKey 流动性提供者的地址、tickLower、tickUpper、salt 等参数哈希得到,确保每个 LP 在每个区间的头寸唯一。
positionKey的计算公式如下:
positionKey = keccak256(abi.encodePacked(owner, tickLower, tickUpper, salt))
Position的状态如下
struct State {
// the amount of liquidity owned by this position
uint128 liquidity;
// fee growth per unit of liquidity as of the last update to liquidity or fees owed
uint256 feeGrowthInside0LastX128;
uint256 feeGrowthInside1LastX128;
}
当有用户添加流动性时,合约会用 (params.owner, tickLower, tickUpper, params.salt)
这四个参数,通过 calculatePositionKey
计算出唯一的 positionKey
,然后:
-
如果该
positionKey
对应的 position 还不存在(即首次在该区间添加流动性),就会初始化一个新的 Position.State,并设置初始的 liquidity 和手续费快照。 -
如果该 position 已存在,则会在原有的基础上增加流动性,并更新相关状态(如手续费快照)。
每个 position 都由 (owner, tickLower, tickUpper, salt)
唯一确定,即使区间有重叠,只要四元组不同,就会生成不同的 position。这样,每个 LP 在每个区间(甚至同一区间不同 salt)下的流动性头寸都能被独立追踪和管理。这正是 Uniswap V3/V4 支持“区间流动性”和多头寸的核心机制。
手续费
下面的三对参数需要结合起来看
struct Pool.State {
uint256 feeGrowthGlobal0X128;
uint256 feeGrowthGlobal1X128;
}
struct TickInfo {
uint256 feeGrowthOutside0X128;
uint256 feeGrowthOutside1X128;
}
struct Position.State {
// fee growth per unit of liquidity as of the last update to liquidity or fees owed
uint256 feeGrowthInside0LastX128;
uint256 feeGrowthInside1LastX128;
}
-
feeGrowthGlobal0X128/1X128
:自池子初始化以来,每 1 单位流动性 在 token0/token1 上累计获得的手续费(全局累计,X128 精度)。
在每次交易中,手续费会按比例分配到 feeGrowthGlobal
中:
if (result.liquidity > 0) {
unchecked {
step.feeGrowthGlobalX128 += UnsafeMath.simpleMulDiv(
step.feeAmount, FixedPoint128.Q128, result.liquidity
);
}
}
手续费的计算公式为:feeGrowthGlobal +=本次交易手续费 / 全局流动性
接下来是TickInfo中的feeGrowthOutside,这个参数记录了当前tick的“外部”手续费,“外部”这个概念很关键,假设现在有一个tickA,当前价格所在的位置为tickC,那么tickC相对tickA所在的方向就是tickA的内部,相反的方向为外部,图解如下:
|<-- 外部 -->| tickA |<-- 内部 -->| tickC
tickC |<-- 内部 -->| tickA |<-- 外部 -->|
假设,价格从左往右第一次穿过tickA,来到了tickA的右侧,那么tickA的feeGrowthOutside就是tickA左边产生的所有手续费此时为feeGrowthGlobal,
|<-- 外部(左侧) -->| tickA | 当前价格 |
feeGrowthOutside = feeGrowthGlobal
接着交易继续在tickA右侧进行,产生的手续费继续累加到feeGrowthGlobal上,当价格从右到左第二次穿过tickA时,此时的feeGrowthOutside就会变成,tickA右侧产生的交易费用
|<--之前的feeGrowthOutside-->|当前价格| tickA |<--外部(右侧)--> |
|<-- 总的交易手续费feeGrowthGlobal --> |
这个时候feeGrowthOutside就是当前全局的交易费用减去之前tickA左侧产生的交易费用
即 feeGrowthOutside=feeGrowthGlobal - feeGrowthOutside。
接下来看feeGrowthInside参数,在理解“外部”手续费的前提下,内部手续费就很容易理解了。其表示两个tick之间的手续费累计:
|tickLower|<--内部手续费--> |tickUpper|
以下代码是通过两个tick的外部手续费计算二者之间的内部手续费,在理解“外部”含义的情况下很容易读懂
function getFeeGrowthInside(State storage self, int24 tickLower, int24 tickUpper)
internal
view
returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128)
{
TickInfo storage lower = self.ticks[tickLower];
TickInfo storage upper = self.ticks[tickUpper];
int24 tickCurrent = self.slot0.tick();
unchecked {
if (tickCurrent < tickLower) {
feeGrowthInside0X128 = lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128;
feeGrowthInside1X128 = lower.feeGrowthOutside1X128 - upper.feeGrowthOutside1X128;
} else if (tickCurrent >= tickUpper) {
feeGrowthInside0X128 = upper.feeGrowthOutside0X128 - lower.feeGrowthOutside0X128;
feeGrowthInside1X128 = upper.feeGrowthOutside1X128 - lower.feeGrowthOutside1X128;
} else {
feeGrowthInside0X128 =
self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128;
feeGrowthInside1X128 =
self.feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128 - upper.feeGrowthOutside1X128;
}
}
}
每当 position 的状态发生变更(即领取手续费或流动性变化),都会调用此方法重新计算内部手续费,并更新到postion的feeGrowthInsideLast字段中。