
Solana 算术:构建金融应用的最佳实践
感谢 0xIchigo 和 Lostin 编辑本文并做出贡献。
Solana 领域竞争激烈。无论你在开发新的借贷协议、聚合器、预测市场、RWA 代币化项目,还是其他产品,都很容易急于上线主网。
但务必牢记,区块链应用负责管理价值,因此本质上都是金融应用。
如果你以前没有开发过金融应用,无论是区块链应用还是传统金融应用,都应该了解金融数学的重要性。
关于 Solana 特有的编程主题,有许多优秀资源,例如检查每条指令需要哪些账户签名、所用账户归哪些程序所有,以及如何避免账户重开攻击等。Anchor 的账户约束让其中许多检查更容易实现。Rust 也提供了一些合理的默认机制,例如在 Debug 模式下捕获整数上溢和下溢。
但安全的金融编程并不只有 Solana 特有的问题。 代币算术中的一个错误就可能造成资产泄漏、意外通胀,并激怒用户。Solana 的交易量高于其他区块链,因此漏洞也可能被更快利用。
传统金融中的许多编程技术与区块链无关,所以没有得到应有的重视,但它们对保护用户的代币仍然至关重要。
本文将重点介绍:
- 使用整数和最小单位
- 避免精度损失——先乘后除
- 采用一致的舍入策略
- 使用无浮点数的利息计算
使用整数和最小单位
精度不足是智能合约中的常见漏洞。数学运算不精确时,就可能产生安全风险。我们需要使用整数和最小单位,在 Solana 应用中安全地执行数学运算。
来看一个基本示例。
在传统金融中,你进行过的每笔“美元”交易实际上都以美分处理。如果使用英镑,则以便士处理。
同样,SOL 交易应以 lamport 处理,USDC 交易应以百万分之一 USDC 处理。
美分、便士和 lamport 都属于最小单位(也称为基本单位)。所有运算都使用最小单位,原因很简单:计算机无法准确处理浮点数。
下面是二进制表示的一:
| 三十二 | 十六 | 八 | 四 | 二 | 一 |
| 0 | 0 | 0 | 0 | 0 | 1 |
下面是二进制表示的九:
| 三十二 | 十六 | 八 | 四 | 二 | 一 |
| 0 | 0 | 1 | 0 | 0 | 1 |
但是,要如何表示 0.3 USDC?
正确答案是:无法准确表示:
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);结果如下:
程序日志:"0.1 + 0.2 = 0.30000000000000004"
正确做法是将美元、英镑、SOL、USDC 和其他所有“货币”都视为其最小单位的整数数量。
例如,使用 USDC 将 0.1 和 0.2 相加:
let answer_ints: u128 = 100000 + 200000;
msg!("100000 + 200000 = {}", answer_ints);结果如下:
程序日志:"100000 + 200000 = 300000"
改用最小单位就不会产生损失。
开发者应使用代币的小数位数,同时牢记:并非所有代币的小数位数都相同。
对代币数量使用整数似乎理所当然,但你还需要在所有地方使用整数,而不仅仅是代币数量。
百分比
百分比应使用整数表示。大多数人使用基点(有时称为“bips”),表示为 bps。
例如,4.74% 等于 474 bps。
复利
不应使用 e(欧拉数)*,*来计算复利。它表示复利频率趋近无穷大时的极限。
使用 e 可以实现“连续复利”,也就是让复利形成一条平滑曲线,但 e 本身在 Rust 中用浮点数表示。
使用 e 不仅会产生舍入误差,计算结果也会与其他机制不同。
本文稍后将演示这两点。
上溢和下溢
Rust 将整数存储为固定大小的变量。这意味着,根据变量是有符号还是无符号,它在内存中只能占用一定的空间。
例如,u8 类型可以存储 0 到 255 之间的任意值。但如果存储超出该范围的值,就会发生上溢或下溢。
什么是上溢?
当值超过该变量类型能够存储的最大值,并回绕到最小值时,就会发生上溢。
例如,如果尝试在 u8 中存储 256,它会回绕到 0。257 会回绕到 1,258 会回绕到 2,511 会回绕到 255,而 512 又会回绕到 0。
什么是下溢?
当值低于最小可能值并回绕到最大值时,就会发生下溢。对于 u8,-1 会回绕到 255,-2 会回绕到 254,依此类推。 简而言之,下溢与上溢类似,只是发生的方向相反。
Rust 提供了多项检查,在发生上溢或下溢时让程序在运行时 panic。但以 release 模式编译时,这些检查不会启用。而 Solana 开发环境中的核心工具链默认会以 release 模式编译 Solana 程序。
要进一步了解上溢和下溢及其缓解方法,请参阅我们的 Solana 程序安全指南。
先乘后除
先除后乘通常感觉更自然。以构建预测市场为例。当用户押中获胜结果时,我们必须计算奖金。预测市场中的获胜者会根据自己在获胜结果总投注额中的占比获得奖金。直观来看,公式可以写成:
奖金池 ÷ 获胜结果的总投注额 × 投注金额
这看起来非常自然。
我们先将奖金池分成代表一“份”奖金的小份额,再计算每个人应获得多少份。
但如果先乘,得到的中间结果会更大,从而降低后续除法舍入误差的影响。
奖金池 × 投注金额 ÷ 获胜结果的总投注额
预测市场示例
下面快速演示一下。如果由人而不是计算机进行运算,正确答案应为 10.5。
先尝试做除法:
let answer: u128 = 7 / 2 * 3;
msg!("7 / 2 * 3 = {}", answer);结果如下:
程序日志:"7 / 2 * 3 = 9"
先做除法会损失 1.5 个最小单位。
如果先做乘法呢?
let answer: u128 = 7 * 3 / 2;
msg!("7 * 3 / 2 = {}", answer);结果如下:
程序日志:"7 * 3 / 2 = 10"
先做乘法时,舍入损失只有 0.5 个最小单位。
这个概念可以进一步扩展为:“先执行所有会提高数量级的运算。”
例如,如果要计算包含幂或根的复杂表达式(如计算风险),应先计算幂,再计算根。这是因为在定点算术中,如果先做除法,商在乘法之前向下舍入,就可能导致精度损失。
采用一致的舍入策略
舍入策略不一致会导致单个代币之间出现微小差异,让代币看起来像是凭空消失或产生。长此以往,这可能耗尽程序的流动性,或造成意外的代币通胀。
正如 Orca 的 Will Thieme 在 Breakpoint 演讲 Solana 程序常见陷阱应对指南中指出的那样,多获得一个代币似乎并不算多。
但 Solana 上存在真实的(按传统金融的定义)交易,这些交易可以包含多条指令,而且交易费用很低。因此,可以在一笔交易中塞入大量单独的指令,利用差一错误,并以极低的成本执行。
舍入误差无法避免,但可以通过一致的舍入策略来控制。应提前决定向上还是向下舍入,以及如何处理“正好一半”(即 0.5)。四舍五入更常见,并且是部分金融标准的强制要求。最重要的是,在整个代码库中始终一致地应用该策略。
Rust 特有的舍入注意事项
舍入运算经常造成精度损失,因此开发者应了解一些可能带来问题的 Rust 特有函数。选择哪种舍入方法,会显著影响程序的准确性和行为。
四舍五入与向下取整
例如,try_round_u64() 函数会舍入到最接近的整数。如果要构建一个将抵押品转换为流动性的程序,向上舍入可能导致铸造的流动性代币超过抵押品实际支持的数量。此时应改用 try_floor_u64() 函数,向下舍入到最接近的整数。
饱和算术函数
此外,开发者经常使用 saturating_* 算术函数(例如 saturating_add),将值限制在相应的最大值和最小值,以防止上溢和下溢。但这些函数可能造成难以察觉的精度损失。
例如,如果函数将交易金额乘以奖励倍数,而乘积超过该变量类型的最大值,用户获得的奖励就会少于应得金额。
务必牢记这一点,尤其是因为你的程序应该使用定点算术。
使用无浮点数的利息计算
计算复利的常见方法是使用欧拉数 e,但 e 是浮点数! 应避免使用浮点数计算利息,改用定点算术。
Solana 的 spl-math 库提供了 PreciseNumber。顾名思义,它可在 Solana 功能较受限的 Rust 环境中运行,能够表示极小的小数(最多 12 位小数),同时保持精确度。
use spl_math::precise_number::PreciseNumber;
fn calculate_compound_interest(
principal: u128,
rate_basis_points: u128,
time: u128,
compounds_per_year: u128,
) -> u128 {
// Formula: result = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
// Where rate_basis_points is expressed in basis points (500 for 5%)
// Convert principal to PreciseNumber
let principal = PreciseNumber::new(principal).unwrap();
// Convert basis points to decimal percentage (divide by 10000)
let rate = PreciseNumber::new(rate_basis_points)
.unwrap()
.checked_div(&PreciseNumber::new(10_000).unwrap())
.unwrap();
// Calculate rate/compounds_per_year
let rate_per_period = rate
.checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
.unwrap();
// Calculate 'base', which is 1 + rate/compounds_per_year
let one = PreciseNumber::new(1).unwrap();
let base = rate_per_period.checked_add(&one).unwrap();
// Calculate 'total_periods', which is compounds_per_year * time
let total_periods = compounds_per_year.checked_mul(time).unwrap();
// Calculate compound_factor, which is (1 + rate/compounds_per_year)^(compounds_per_year * time)
let compound_factor = base.checked_pow(total_periods).unwrap();
// Calculate result = principal * compound_factor
principal
.checked_mul(&compound_factor)
.unwrap()
.to_imprecise()
.unwrap()
}
运行代码即可看到差异:
- 程序日志:"使用 spl-math
PreciseNumber" - 程序日志:"$1000 的投资以 5% 的利率投资 5 年,每年复利 1 次:"
- 程序日志:"最终金额:$1276"
相比之下,使用 e 时:
- 程序日志:" 使用 e(不要这样做)"
- 程序日志:"$1000 的投资以 5% 的利率投资 5 年,每年复利 1 次:"
- 程序日志:"最终金额:$1284"
总结
代币数学是个枯燥的话题,但忽视它可能会带来你绝不想要的“刺激”。你的链上应用应以用户要求的精度和安全性处理代币。
本文介绍了如何使用整数和最小单位、先乘后除、避免精度损失、保持一致的舍入策略,以及在计算中使用无浮点数的利息计算方法。
掌握这些算术基础后,请在主网上线前让其他人审查你的代码,最好是专注于 Solana 的审计公司。
其他资源
- Solana 程序安全指南 - Helius 博客
- 了解 Rust 和 Solana 智能合约中的算术上溢与下溢 – Sec3 博客
相关文章
订阅 Helius
及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新


