本文深入探讨了在Solana区块链上开发金融应用时,如何确保安全的数字计算。重点包括使用整数和小单位、避免精度损失、实施一致的四舍五入策略以及使用无浮点数的利息计算。文章还强调了传统金融编程技巧的重要性,避免了常见的编程错误,使得开发者能够更好地处理用户的资产安全。
9分钟阅读
2025年3月4日
感谢0xIchigo和Lostin编辑和贡献这篇文章。
Solana是一个竞争激烈的领域——无论你是在开发新的借贷协议、聚合器、预测市场、现实世界资产(RWA)代币化还是其他任何东西,急于进入主网是很有吸引力的。
然而,必须记住,由于其对价值的管理,区块链应用本质上是金融应用。
如果你以前没有在区块链或传统金融上构建过金融应用,你应该了解金融数学的重要性。
有许多关于Solana特定编程主题的优秀资源——例如,检查哪些账户需要签署每个指令、你使用的账户的拥有程序、避免账户重新开启攻击等等。Anchor的账户约束使实现许多这些检查更加可管理,而Rust也有一些智能的默认设置,像是在调试模式下捕捉整数溢出和下溢。
但是,安全的金融编程不仅仅涉及Solana特定主题。 在代币算术中的一个错误可能导致漏洞、意外通货膨胀和愤怒的用户。在Solana上,交易量高于其他区块链,漏洞可以被更快地利用。
许多来自传统金融的编程技术与区块链无关——因此它们没有得到应有的关注——但仍然对保护用户的代币至关重要。
在本文中,我们将具体讨论:
精度缺失是智能合约中的一个常见漏洞。当数学操作不精确时,它们可能变得脆弱。我们需要使用整数和小单位在Solana应用中执行安全的数学操作。
让我们来看一个基本示例。
在传统金融中,你曾经以“美元”进行的每一次交互实际上是以分进行的。如果你使用的是英镑,则是以便士进行的。
同样,你在SOL中的交易应以lamports处理,而你在USDC中的交易应以百万分之一USDC处理。
分、便士和lamports都是小单位(也称为基本单位),使用小单位的原因很简单:计算机无法处理浮点数字。
这是二进制中的一个例子:
三十二 | 十六 | 八 | 四 | 二 | 一 |
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基点。
复利不应使用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有多种检查,会在运行时导致程序在发生溢出或下溢时恐慌,但在发布模式下编译时不包括这些检查。而且,Solana开发环境中所需的工具链默认以发布模式编译Solana程序。
要了解更多关于溢出和下溢的信息,以及如何减轻这些问题,请查看我们的Solana程序安全指南。
通常在先除后乘时感觉很自然——让我们以构建预测市场的例子来说明。当用户对获胜结果下注时,我们必须计算获胜支付。在预测市场中,获胜者的支付是基于他们在获胜结果下注的比例。心理上,这很自然地转换为:
赢池 ÷ 获胜结果的总下注 × 下注金额
这似乎很自然。
我们首先将赢池分割为代表1份赢利的小部分,然后计算应该分给人们多少份。
但是如果我们先乘,我们将得到一个更大的中间结果。这使我们能够减少随后除法的舍入错误的影响。
赢池 × 下注金额 ÷ 获胜结果的总下注
这里有一个快速演示。如果我们以人而不是计算机来进行这个计算,正确的答案应该是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特定函数,因为舍入操作通常会导致精度损失。舍入方法的选择可能会显着影响程序的准确性和行为。
例如,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 {
// 公式:结果 = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
// 其中rate_basis_points以基点表示(500表示5%)
// 将本金转换为PreciseNumber
let principal = PreciseNumber::new(principal).unwrap();
// 将基点转换为小数百分比(除以10000)
let rate = PreciseNumber::new(rate_basis_points)
.unwrap()
.checked_div(&PreciseNumber::new(10_000).unwrap())
.unwrap();
// 计算rate/periods_per_year
let rate_per_period = rate
.checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
.unwrap();
// 计算‘base’,即1 + rate/compounds_per_year
let one = PreciseNumber::new(1).unwrap();
let base = rate_per_period.checked_add(&one).unwrap();
// 计算‘total_periods’,即compounds_per_year * time
let total_periods = compounds_per_year.checked_mul(time).unwrap();
// 计算compound_factor,即 (1 + rate/compounds_per_year)^(compounds_per_year * time)
let compound_factor = base.checked_pow(total_periods).unwrap();
// 计算结果 = principal * compound_factor
principal
.checked_mul(&compound_factor)
.unwrap()
.to_imprecise()
.unwrap()
}
你可以通过运行代码看到差异:
PreciseNumber
"与使用e时相比:
代币数学是一个无聊的话题,但不关注它可能导致你不想要的那种激动。你的链上应用应以用户所需的精度和安全性处理代币。
在这篇文章中,我们讨论了使用整数和小单位、乘法、避免精度损失、维护一致的舍入政策和无浮点数的利息计算。
在你掌握这些算术基础知识后,找其他人——特别是一家专注于Solana的审计公司——在主网上线前检查你的代码。
- 原文链接: helius.dev/blog/solana-a...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!