在去中心化金融(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));
// ... 魔法发生在这里 ...
}
为了在累积乘法的“漫漫长路”上最大限度地保留精度,函数内部的计算逻辑别有洞天。
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} $。
接下来,函数通过一系列的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位的小数部分保留了海量的计算细节,确保每一步的误差都小到可以忽略不计,从而有效地对抗了误差累积。
当所有基于二进制分解的乘法步骤完成,ratio
变量已经是一个在Q32.96格式下高度精确的结果。然而,函数并没有直接返回这个值。
步骤 3:格式转换与最终输出
在函数的最后,我们看到了一个看似简单的操作:
ratio >> 32
这个右移32位的操作,是第二重魔法的关键。它将内部的Q32.96格式(代表数值 $ V \times 2^{96} $)转换为了整个 tick_math
模块通用的标准格式——Q64.64(代表数值 $ V \times 2^{96} $)。
这一转换至关重要,它实现了两个目标:
sqrt_price
的“标准格式”。无论是模块顶层定义的 MIN_SQRT_PRICE_X64
和 MAX_SQRT_PRICE_X64
常量,还是反向计算函数 get_tick_at_sqrt_price
,都统一采用Q64.64格式。这确保了模块各部分能够无缝协作。范围与精度的平衡: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 格式下完成。
在处理负 tick
时,函数的核心逻辑仍是乘法,而非除法。它通过累积乘以一系列预先计算好的倒数因子来得到最终结果,并且整个计算过程。这种设计的背后,是对负 tick
价格区间特性(始终在0和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}} $。
接下来,函数检查 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
之所以采用这种更直接的方法,主要有两个原因:
tick
为负数时,其对应的 P 永远在 (0, 1)
区间内。这意味着其整数部分永远为0。因此,Q64.64格式提供的64位小数位已足够精确,而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-->
📹 课程B站账号
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!