uniswap v3/v4 中pool的状态管理

文章详细介绍了UniswapV3中tick的存储结构和价格计算方法,包括tick的流动性管理,Q64.94精度的定点数表示,以及如何通过位图高效管理大量ticks。在价格变动时,根据tick的liquidityNet和liquidityGross更新流动性,并展示了计算sqrtPriceX96的复杂但优化过的算法过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

首先看一下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 bitssqrtPriceX96 - 当前价格的平方根,编码为 Q96 格式。
  • 24 bitstick - 当前的 tick 值。
  • 12 bitsprotocolFee (方向 0->1) - 协议费用的上 12 位。
  • 12 bitsprotocolFee (方向 1->0) - 协议费用的下 12 位。
  • 24 bitslpFee - 当前流动性提供者的费用。
  • 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 V3Uniswap 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字段中。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值