深入Sui CLMM:tick_math中的确定性艺术(上)——从Tick到Price的幂运算魔法

在去中心化金融(DeFi)的世界里,自动做市商(AMM)的每一次迭代都旨在解决一个核心问题:如何提高资金的利用效率,从UniswapV3开始,集中流动性做市商(CLMM)给出了一个革命性的答案,其魅力至今仍在Sui、Aptos等高性能公链的DeFi生态中大放异彩。而这一切高效的背后,都离不开一个

在去中心化金融(DeFi)的世界里,自动做市商(AMM)的每一次迭代都旨在解决一个核心问题:如何提高资金的利用效率,从 UniswapV3 开始,集中流动性做市商(CLMM)给出了一个革命性的答案,其魅力至今仍在Sui、Aptos等高性能公链的DeFi生态中大放异彩。而这一切高效的背后,都离不开一个优雅而坚实的数学基石——价格刻度(Tick)系统。

这个系统可以将无限的价格曲线,离散化为一个个精确的,可计算的“刻度”。每个 tick都与一个特定的价格 Price精确对应。它们之间的关系由一个看似简单的公式定义:

$ \text{Price}_{\text{tick}} = 1.0001^{\text{tick}} $

为了在计算中避免开方运算,协议通常会使用价格的平方根(sqrt_price)进行操作,公式也随之演变为:

$ \sqrt{\text{Price}_{\text{tick}}} = \sqrt{1.0001}^{\text{tick}} \approx (1.00001)^{\frac{tick}{2}} $

然而,一个巨大的挑战摆在所有智能合约工程师面前:在智能合约中,进行幂运算的gas消耗成本极高,幂运算gas的消耗与指数的大小密切相关,指数越大,消耗越多。那么,像 Cetus Protocol 这种热门DeFi的tick_math的模块,是如何在没有幂函数的情况下,精确计算出任意tick对应的sqrt_price的呢?

答案,就隐藏在古老的数学智慧与精巧的工程设计之中。本文,我们将深入探索tick_math模块,揭示 Cetus Protocol在工程实践中将 Tick 转换为 Price 的第一重魔法:二进制分解(Binary Decomposition)与定点数算术(Fixed-Point Arithmetic

核心原理:化“幂”为“乘”的数学之美

让我们暂时忘掉复杂的代码,回到高中数学的课堂。指数运算有一个基本性质:

$ a^{(x+y)} = a^x \times a^y $

这个简单的恒等式是解决链上幂运算问题的金钥匙。它告诉我们,指数的相加,等于底数的相乘

现在,让我们利用这个性质。任何一个整数 **tick**都可以被分解为其二进制表示中所有为**1**的位对应的**2**的幂之和。听起来有点绕?举个例子就清楚了。

假设我们要计算tick = 21时的价格,而21的二进制表示是10101,这意味着:

$ 21 = 16 + 4 + 1 = 2^4 + 2^2 + 2^0 $

那么,我们最初的计算目标$ \left(\sqrt{1.0001}\right)^{21} $就被巧妙的转换成了:

$ \left(\sqrt{1.0001}\right)^{16 + 4 + 1} = \left(\sqrt{1.0001}\right)^{16} \times \left(\sqrt{1.0001}\right)^4 \times \left(\sqrt{1.0001}\right)^1 $

看!一次复杂且昂贵的幂运算,被分解成了一系列简单的乘法。我们只需要预先计算并存储$ \left(\sqrt{1.0001}\right)^1,\left(\sqrt{1.0001}\right)^2,\left(\sqrt{1.0001}\right)^4,\left(\sqrt{1.0001}\right)^8 $等这些基础值,就可以通过组合相乘,得到任意tick对应的价格。

tick_math模块正是这样做的。代码中类似if (abs_tick & 0x1 != 0)这样的位运算,就是在检查tick的二进制表示,哪些位是1,然后将对应的预计算值累积乘到最终结果上。

正向计算:代码深度解析 get_sqrt_price_at_positive_tick 的双精度魔法

tick 为正数时,价格会持续增大。这带来的核心挑战并非简单的整数溢出,而是一个更棘手、更隐蔽的敌人——计算过程中的精度损失(Precision Loss)。在将许多略大于1.0的数字(例如 $ \left(\sqrt{1.0001}\right)^{2^{k}} $)连续相乘时,每一步计算产生的微小舍入误差都会被后续的乘法指数级放大。在金融场景中,这种偏差累积到最后可能会导致资产计价的显著错误,这是绝对无法接受的。

面对这一挑战,Cetus 的工程师们并未采用单一的解决方案,而是施展了一套精巧的“双精度魔法”:在计算过程中采用一种超高精度的定点数格式,在输出时再将其转换为模块通用的标准格式。让我们结合具体代码,一步步揭示这套魔法的奥秘。

// 这是一个简化的函数结构,源自 tick_math.move 模块
public fun get_sqrt_price_at_positive_tick(tick: i32::I32): u128 {
    let abs_tick = i32::as_u32(i32::abs(tick));

    // ... 魔法发生在这里 ...

}

内部计算的极致精度 —— Q32.96 格式

为了在累积乘法的“漫漫长路”上最大限度地保留精度,函数内部的计算逻辑别有洞天。

步骤1:初始化高精度比率ratio

计算的第一步是根据 tick 的二进制表示的最低位(0x1,即奇偶性)来初始化一个名为 ratio 的变量

  let ratio = if (abs_tick & 0x1 != 0) {
        // tick 是奇数,即最低位为 1 时,初始值为 sqrt(1.0001) * 2^96
        79232123823359799118286999567u128
    } else {
        // tick 是偶数,即最低位位 0 时,初始值为 1 * 2^96
        79228162514264337593543950336u128
    };

这里的两个“魔术数字”是关键。它们并不是任意的,而是以极高的精度预先计算好的定点数:

  • 79228162514264337593543950336 精确等于 $ 2^{96} $
  • 79232123823359799118286999567 约等于 $ \sqrt{1.0001} \times 2^{96} $

    这一步揭示了魔法的核心:ratio 变量在内部是以 Q32.96 定点数格式进行处理的。这意味着在一个u128的存储空间里,工程师们奢侈地划分出了整整 96位 用于表示小数部分,其缩放因子为惊人的 $ 2^{96} $。

步骤2:基于二进制分解的迭代乘法

接下来,函数通过一系列的if条件判断,检查tick的每一个二进制位,并将对应的预计算值累积乘到ratio上。

if (abs_tick & 0x2 != 0) { // 检查第 2 位
        // ratio = ratio * (sqrt(1.0001^2) * 2^96) / 2^96
        ratio = full_math_u128::mul_shr(ratio, 79236085330515764027303304731u128, 96u8);
    };
    if (abs_tick & 0x4 != 0) { // 检查第 3 位
        // ratio = ratio * (sqrt(1.0001^4) * 2^96) / 2^96
        ratio = full_math_u128::mul_shr(ratio, 79243929628373836703953683893u128, 96u8);
    };
    // ... 对 tick 的所有相关位重复此过程 ...
    if (abs_tick & 0x40000 != 0) { // 检查第 19 位
        ratio = full_math_u128::mul_shr(ratio, 38992368544603139932233054999993551u128, 96u8);
    };

这里的 full_math_u128::mul_shr(a, b, 96) 函数至关重要。它执行 (a * b) >> 96 操作。因为 a (ratio) 和 b (常量) 都是Q32.96格式,它们的乘积会变成一个Q64.192格式的数(小数位数翻倍)。通过右移96位,函数将结果重新调整回Q32.96格式,从而在整个迭代过程中保持了格式的一致性和极高的精度。

选择Q32.96格式的意图非常明确:在迭代乘法的每一步都追求极致的精确。这96位的小数部分保留了海量的计算细节,确保每一步的误差都小到可以忽略不计,从而有效地对抗了误差累积。

标准化的输出 —— Q64.64 格式

当所有基于二进制分解的乘法步骤完成,ratio 变量已经是一个在Q32.96格式下高度精确的结果。然而,函数并没有直接返回这个值。

步骤 3:格式转换与最终输出

在函数的最后,我们看到了一个看似简单的操作:

ratio >> 32

这个右移32位的操作,是第二重魔法的关键。它将内部的Q32.96格式(代表数值 $ V \times 2^{96} $)转换为了整个 tick_math 模块通用的标准格式——Q64.64(代表数值 $ V \times 2^{96} $)。

这一转换至关重要,它实现了两个目标:

  1. 一致性与互操作性:Q64.64是模块内sqrt_price的“标准格式”。无论是模块顶层定义的 MIN_SQRT_PRICE_X64MAX_SQRT_PRICE_X64 常量,还是反向计算函数 get_tick_at_sqrt_price,都统一采用Q64.64格式。这确保了模块各部分能够无缝协作。
  2. 范围与精度的平衡Q64.64格式为最终的价格平方根提供了一个更为均衡的方案。64位的小数部分足以满足最终价格表示的精度需求,而64位的整数部分则提供了宽广的数值范围,赋予系统极高的健壮性。

    因此,Cetus 解决正向计算挑战的真正秘诀,是这一套由代码实现的组合拳:首先通过二进制分解将幂运算转化为乘法,然后在计算过程中切换到Q32.96的“高精度模式”以对抗误差累积,最后再切换回Q64.64的“标准模式”以确保整个系统的和谐统一。这不仅是数学智慧的体现,更是卓越工程实践的典范。

反向计算:剖析 get_sqrt_price_at_negative_tick 的直接计算法

与价格随 tick 增大的正向计算不同,当 tick 为负数时,价格会持续减小($ P < 1 $)。其核心数学关系为:

$ P(\text{tick}) = (1.0001)^{-\frac{|\text{tick}|}{2}} = \frac{1}{(1.0001)^{\frac{|\text{tick}|}{2}}} $。

面对这种情况,Cetus 的工程师们采用了一种与“双精度魔法”截然不同的策略。他们没有先计算出 $ (1.0001)^{\frac{|\text{tick}|}{2}} $ 再进行昂贵的除法运算,而是巧妙地将问题转化为一系列倒数的乘法,并且全程在模块标准的 Q64.64 格式下完成。

化繁为简:全程使用 Q64.64 格式的倒数乘法

在处理负 tick 时,函数的核心逻辑仍是乘法,而非除法。它通过累积乘以一系列预先计算好的倒数因子来得到最终结果,并且整个计算过程。这种设计的背后,是对负 tick 价格区间特性(始终在0和1之间)的深刻洞察。

步骤 1:以标准格式初始化

计算的起点直接就是模块的标准Q64.64格式

fun get_sqrt_price_at_negative_tick(tick: i32::I32): u128 {
    let abs_tick = i32::as_u32(i32::abs(tick));
    let ratio = if (abs_tick & 0x1 != 0) {
        // tick 是奇数, 初始值为 (1 / sqrt(1.0001)) * 2^64
        18445821805675392311u128
    } else {
        // tick 是偶数, 初始值为 1 * 2^64
        18446744073709551616u128
    };

这里的初始值是关键:

  • 18446744073709551616 精确等于 $ 2^{64} $
  • 18445821805675392311 约等于 $ \frac{1}{\sqrt{1.0001}} \times 2^{64} $

    ratio 从一开始就是 Q64.64 格式,其代表的实际值是 $ 1 $ 或者 $ \frac{1}{\sqrt{1.0001}} $。

步骤 2:基于二进制分解的倒数因子迭代乘法

接下来,函数检查 abs_tick 的每一个二进制位,并将对应的、预先计算好的倒数因子累积乘到 ratio 上。

if (abs_tick & 0x2 != 0) {
        // ratio = ratio * (1 / sqrt(1.0001^2))
        // 所有操作数和结果均为 Q64.64 格式
        ratio = full_math_u128::mul_shr(ratio, 18444899583751176498u128, 64u8)
    };
    if (abs_tick & 0x4 != 0) {
        // ratio = ratio * (1 / sqrt(1.0001^4))
        ratio = full_math_u128::mul_shr(ratio, 18443055278223354162u128, 64u8);
    };
    // ... 对 tick 的所有相关位重复此过程 ...

这里的 full_math_u128::mul_shr(a, b, 64) 函数执行 (a * b) >> 64。这正是两个Q64.64格式定点数相乘的标准操作,结果依然保持为Q64.64格式。代码中所有的“魔术数字”常量,都是 $ \frac{1}{(1.0001)^{2^{k}}} $ 在Q64.64格式下的表示。通过这种方式,函数高效地计算出了最终的倒数值,而无需任何除法。

一以贯之:为何选择直接计算?

get_sqrt_price_at_negative_tick之所以采用这种更直接的方法,主要有两个原因:

  1. 数值范围的确定性:当 tick 为负数时,其对应的 P 永远在 (0, 1) 区间内。这意味着其整数部分永远为0。因此,Q64.64格式提供的64位小数位已足够精确,而64位整数位(虽然在这里用不上)则绰绰有余。没有必要为了更高的中间精度而引入复杂的格式转换。
  2. 逻辑简化与效率:既然Q64.64格式足以应对负 tick 的计算,那么全程使用这一标准格式就避免了额外的格式转换步骤(如正向计算最后的 >> 32)。这使得函数逻辑更清晰、更统一,并且最终直接返回计算结果ratio,没有任何多余操作。

总结:量身定制的数学艺术

Cetus针对正负 tick 计算的不同数值特性,量身定制了两种不同的实现策略。这种差异化设计绝非偶然,而是深思熟虑后对精度、范围和效率进行精妙权衡的结果。

  • 对于正 **tick**:价格可能增长到巨大数值,对计算过程中的精度损失极为敏感。因此,工程师采用了“内部高精度,外部标准化”的双轨制。内部使用 Q32.96 格式进行迭代乘法,以96位小数的极致精度对抗误差累积;外部则转换为标准的 Q64.64 格式,确保了整个模块的统一和兼容。
  • 对于负 **tick**:价格始终被限制在 (0, 1) 区间,数值范围可控。因此,工程师选择了“全程标准化”的单轨制。计算全程使用 Q64.64 格式,通过乘以一系列倒数因子来避免除法,逻辑更简洁,效率也更高。

    这套非对称的设计方案,充分展示了DeFi工程师在资源受限的智能合约环境中,进行精细化优化设计的智慧。它告诉我们,最优解往往不是一成不变的,而是需要根据具体问题的不同特性,灵活地选择和定制最合适的工具与方法。

附录:模块常量解析

为了更好地理解 tick_math 模块的行为,解析其定义的几个核心常量至关重要。

常量名 定义值 含义与设计原因
TICK_BOUND 443636 定义了 **tick** 的最大绝对值tick 的有效范围是 [-443636, 443636]。这个边界值的选择是为了确保在 u128 的存储限制下,价格的平方根(P)既不会溢出,又能保持足够的精度。具体来说,最大的 P 值为 $ (1.0001)^{\frac{443636}{2}} \approx 231.999\ldots $,其值刚好可以用32位整数表示。这与 get_sqrt_price_at_positive_tick 内部使用的 Q32.96 格式完美契合(32位整数 + 96位小数 = 128位)。
MAX_SQRT_PRICE_X64 79226673515401279992447579055 定义了系统支持的最大平方根价格,采用 Q64.64 格式表示。这个值是 get_sqrt_price_at_tick(TICK_BOUND) 的计算结果,即 $ \left\lfloor (1.0001)^{\frac{443636}{2}} \times 2^{64} \right\rfloor $。它作为协议可操作价格范围的上限。
MIN_SQRT_PRICE_X64 4295048016 定义了系统支持的最小平方根价格,同样采用 Q64.64 格式。这个值是 get_sqrt_price_at_tick(-TICK_BOUND) 的计算结果,即 $ \left\lfloor (1.0001)^{-\frac{443636}{2}} \times 2^{64} \right\rfloor $。它作为协议可操作价格范围的下限。

<!--EndFragment-->

参考仓库

感谢社区

<!--StartFragment-->

HOH.png 💧  HOH水分子公众号

🌊  HOH水分子X账号

📹  课程B站账号

  • 原创
  • 学分: 8
  • 分类: Move
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
StarryDeserts
StarryDeserts
0xB14f...49CD
江湖只有他的大名,没有他的介绍。