Skip to main content
本页内容由 AI 自动翻译,所有内容以英文版本为准。查看英文版 →

为什么需要 ticks

CLMM 的流动性集中在价格范围内。为了在链上使范围易于处理,价格被量化为整数 ticks,其中每个 tick 是前一个的常数倍数: price(i)=1.0001i\text{price}(i) = 1.0001^{\,i} 一个 tick 对应 0.01% 的价格变动,或约 1 个基点。映射关系如下: MIN_TICKMAX_TICK 的选择确保 sqrt_price_x64 在两端都能适配 u128。每个池都强制 tick_lower >= MIN_TICKtick_upper <= MAX_TICK。实际上,Web UI 会将范围限制在更窄的范围内,以防止用户将流动性锁定在无法到达的 ticks 中。

Tick 间距

池的 AmmConfig 固定了一个 tick 间距 — position 唯一允许用作端点的 ticks。如果 tick_spacing = 60,只有 ticks …, −120, −60, 0, 60, 120, … 是有效的。尝试以端点 31 打开 position 会因 InvalidTickIndex 而回滚。 常见的已发布间距: 间距越粗,需要初始化的 tick 数组越少,打开宽 position 的成本越低,价格边界越模糊。波动性交易对通常在 120 间距等级中;稳定币对在 1 间距等级中。

Tick 数组

池不会在单独的账户中存储每个 tick 的状态。相反,TICK_ARRAY_SIZE 个相邻的 ticks(当前 Raydium CLMM 中为 60 个)被打包到单个 TickArrayState 中。数组的第一个 tick 是其 start_tick_index,它覆盖恰好 TICK_ARRAY_SIZE * tick_spacing 个整数 tick 单位。 对于 tick_spacing = 60TICK_ARRAY_SIZE = 60
  • 每个 tick 数组跨越 60 × 60 = 3600 个整数 ticks。
  • start_tick_index 是 3600 的倍数:…, -7200, -3600, 0, 3600, 7200, …
tick_spacing = 60 时,position 端点 t = 2040 位于 start_tick_index = 0 的 tick 数组中。Position 端点 t = 4200 位于 start_tick_index = 3600 的数组中。

何时创建数组

Tick 数组是延迟初始化的:第一个 引用其中任何 tick 的 position 会初始化该数组并支付租金。Swaps 不会初始化 tick 数组 — 它们使用位图跳过未初始化的数组。SDK 的 open-position 流程检查选定的范围,计算它接触的 tick 数组列表,如果有缺失的数组,会在与 OpenPosition 相同的交易中添加 init_tick_array 指令。

Tick 数组不会被关闭

一旦 tick 数组被初始化,它就会在池的整个生命周期内持续存在。程序提供关闭 tick 数组的路径,即使 initialized_tick_count 返回到零也不行。Tick 数组没有租金恢复;第一个接触数组的 position 支付的租金会永久锁定在该账户中。这是一个有意的权衡:重用现有 tick 数组对后续每个 position 都是免费的,所以一个交易频繁的池只需为每个 (pool, start_tick_index) 槽支付一次租金,无论流动性变化如何。

位图

找到”当前 tick 左/右的下一个已初始化 tick”必须很快 — swap 可能会跨越许多 ticks。池在 PoolState 中为 tick 0 周围 ±1,024 个数组的范围内存储一个每 tick 数组 1 位的位图。在该范围之外(全范围 positions、特殊设置),TickArrayBitmapExtension 提供溢出。 Swap 遍历位图:lowest_set_bit_above(tick_current_array_index) 给出 swap 正在跨越的一侧的下一个具有已初始化 tick 的数组。在该数组内,类似的位扫描定位下一个已初始化的 tick。

liquidity_grossliquidity_net

每个已初始化的 tick 存储两个流动性值:
  • liquidity_gross — 所有将此 tick 作为端点引用的 positions 的 L 之和。当 liquidity_gross 达到零时,tick 变为未初始化,可以从位图中移除。
  • liquidity_net — 当价格向上跨越此 tick(在 tick 空间中从左到右)时,对池级 liquidity有符号变化。如果此 tick 是大小为 L 的 position 的下界,它贡献 +L;如果它是该 position 的上界,它贡献 −L
工作示例:同一池上的两个 positions。
  • Position A:tick_lower = -120tick_upper = 0,流动性 L_A = 100
  • Position B:tick_lower = -60tick_upper = 60,流动性 L_B = 50
逐 tick 状态: 不同 tick_current 值的池级 liquidity
  • tick_current = -180liquidity = 0(任何 position 之前)
  • tick_current = -90liquidity = 100(仅在 A 内)
  • tick_current = -30liquidity = 150(在 A 和 B 内)
  • tick_current = 30liquidity = 50(仅在 B 内)
  • tick_current = 90liquidity = 0(超过两者)
在 swap 期间的每次 tick 跨越时,程序将 liquidity_net(可能为负)添加到 PoolState.liquidity。这正是 Uniswap v3 的机制。

Positions 作为 NFTs

Raydium CLMM position 是一个 NFT。打开 position 会将一个全新的 mint(供应量为 1)铸造到调用者的钱包中,该 mint 的权限由 CLMM 程序拥有。程序将 position 所有权关联到在 CPI 时在该 mint 的 ATA 中持有余额的任何人 后果:
  • Positions 通常是可转移的。 钱包可以通过转移 NFT 来出售或空投 position。新持有者随后可以调用 CollectRewardsIncreaseLiquidity 等。例外是在受限发行者路径下冻结的 position。
  • Positions 在 CLMM 外可寻址。 市场和钱包像显示其他 NFTs 一样显示 positions。SDK 在 mint 元数据上设置合理的 name/symbol
  • Position 的 PDA 从 NFT mint 派生。 你可以找到 PersonalPositionState 而无需知道当前持有者是谁。

受限发行者 positions

2026-08 升级后创建的每个 position NFT mint 都将其 CLMM pool_state 记录为冻结权限。这并不意味着每个新 position 都被冻结。 对于普通池和每个非匹配 position,NFT 代币账户保持未冻结和可转移。池 PDA 无法在 CLMM 程序外签名,CLMM 不提供通用冻结指令。 冻结需要满足以下两个条件:
  1. Position 通过 OpenPositionV2OpenPositionWithToken22Nft 打开。
  2. 至少一个池金库 mint 从程序的硬编码受限发行者列表中携带冻结权限。
只有当两个条件都满足时,CLMM 才会在铸造后立即冻结新创建的 position NFT 账户。OpenPosition V1 不应用此过滤器。参见 reference/program-addresses 了解当前列表。 冻结的 position:
  • 无法将其 NFT 转移到另一个代币账户。
  • 无法更改 NFT 代币账户的所有者。
  • 当记录的所有者签名时,仍可以增加或减少流动性并收集费用或奖励。
  • 仍可以关闭。ClosePosition 使用池 PDA 解冻 NFT 账户,然后在同一指令中销毁 NFT 并关闭 position 账户。
现有 positions 不会被迁移或追溯冻结。升级前创建的 Position NFT mints 保留其之前的冻结权限设置。
关闭冻结 position 的客户端必须将 position 的 pool_state 作为 ClosePosition 的第一个剩余账户附加。声明的 IDL 账户列表保持不变,因此较旧的客户端可以成功打开冻结 position,但稍后可能因 AccountLack 而无法关闭它。在支持这些池之前更新关闭构建器。

Token-2022 positions

CLMM 可以通过 OpenPositionV2 在经典 SPL Token 下铸造 position NFT,或通过 OpenPositionWithToken22Nft 在 Token-2022 下铸造。两个 V2 路径都检查池金库 mints 并应用相同的受限发行者冻结规则。OpenPosition V1 是遗留的经典代币路径,无法服务具有 Token-2022 金库 mints 的池。钱包和市场兼容性不同;Raydium 的 UI 跟踪两个 NFT 程序。

允许范围规则

OpenPosition 时,程序强制执行:
  1. tick_lower < tick_upper
  2. tick_lower % tick_spacing == 0tick_upper % tick_spacing == 0
  3. MIN_TICK <= tick_lowertick_upper <= MAX_TICK
  4. 调用者已提供包含 tick_lowertick_upper 的 tick 数组 — 要么已初始化,要么通过同一交易中的 init_tick_array
  5. 位图扩展账户,如果此 position 扩展到扩展范围。
如果任何检查失败,指令会因 InvalidTickIndexNotApprovedInsufficientLiquidity 而回滚,具体取决于哪个约束。参见 reference/error-codes

“范围内”vs”范围外”

tick_lower <= tick_current < tick_upper 时,position 是范围内的。只有范围内的 positions 对 PoolState.liquidity 有贡献,因此只有它们赚取 swap 费用。 范围外的 position:
  • 持有一个代币的 100%(其范围已经走过的那个)。具体来说,如果 tick_current < tick_lower,position 仅持有 token1(它已经被价格移动”卖出”);如果 tick_current >= tick_upper,它仅持有 token0。
  • 赚取 swap 费用。
  • 确实继续累积奖励,如果池的奖励流向范围外流动性发出 — 但 Raydium 的默认行为是”仅向范围内发出”,与 Uniswap v3 约定相匹配。参见 products/clmm/fees
管理 CLMM positions 的 LPs 花费大部分精力将 positions 保持在范围内,因为价格会移动。

常见集成陷阱

  • 非间距端点。 从目标价格计算 tick 的代码必须在将其传递给 OpenPosition 之前对齐到 tick_spacing 的倍数。SDK 助手(TickUtils.getTickWithPriceAndTickspacing)会执行此操作;自制数学通常不会。
  • 缺失 tick 数组。 打开宽 position 可能需要初始化多个 tick 数组;忘记将它们作为可写账户传递会导致回滚。SDK 的 openPositionFromBase 为你返回列表。
  • Swap 后的陈旧 tick。 tick_current 可以在一次 swap 中跨越许多 ticks。如果你的 UX 从一个 RPC 调用显示”当前 tick”,然后在稍后的调用中打开 position,相对于实时价格的相对位置可能相差数十个 ticks。在签名前重新获取。
  • 带有额外元数据的 Position NFTs。 如果你构建一个识别 Raydium positions 的钱包,使用 position PDA / 程序数据,而不是硬编码的元数据字段。新 position mints 在创建时使用池 PDA 作为 mint 和冻结权限;在铸造单个 NFT 后移除 mint 权限。
  • 假设每个 position 都是可转移的。 在显示转移、市场、托管或销毁和赚取操作之前,读取 NFT 代币账户的 isFrozen 状态。

接下来去哪里

  • Math — swap 步进和费用增长推导,tick 边界参与其中。
  • AccountsTickArrayStatePositionState 布局。
  • Fees and rewards — 范围内性如何控制费用累积。
  • algorithms/clmm-math — 集中流动性公式的共享推导。
来源: