Rust智能合约中的精准数值计算:整数vs浮点数

Rust智能合约养成日记(7):数值精算

往期回顾:

  • Rust智能合约养成日记(1)合约状态数据定义与方法实现
  • Rust智能合约养成日记(2)编写Rust智能合约单元测试
  • Rust智能合约养成日记(3)Rust智能合约部署,函数调用及Explorer的使用
  • Rust智能合约养成日记(4)Rust智能合约整数溢出
  • Rust智能合约养成日记(5)重入攻击
  • Rust智能合约养成日记(6)拒绝服务攻击

1. 浮点数运算的精度问题

不同于常见的智能合约编程语言Solidity,Rust语言原生支持浮点数运算。然而,浮点数运算存在着无法避免的计算精度问题。因此,在编写智能合约时,并不推荐使用浮点数运算(尤其是在处理涉及到重要经济/金融决策的比率或利率时)。

目前主流计算机语言表示浮点数大多遵循IEEE 754标准,Rust语言也不例外。如下是Rust语言中有关双精度浮点类型f64的说明与计算机内部二进制数据保存形式:

浮点数采用了底数为2的科学计数法来表达。例如可以用有限位数的二进制数0.1101来表示小数0.8125,具体的转化方式如下:

0.8125 * 2 = 1 .625 // 0.1      获得第1位二进制小数为1
0.625  * 2 = 1 .25  // 0.11     获得第2位二进制小数为1  
0.25   * 2 = 0 .5   // 0.110    获得第3位二进制小数为0
0.5    * 2 = 1 .0   // 0.1101   获得第4位二进制小数为1

即 0.8125 = 0.5 * 1 + 0.25 * 1 + 0.125 * 0 + 0.0625 * 1

然而对于另一个小数0.7来说,其实际转化为浮点数的过程中将存在如下问题:

0.7 x 2 = 1. 4 // 0.1
0.4 x 2 = 0. 8 // 0.10
0.8 x 2 = 1. 6 // 0.101
0.6 x 2 = 1. 2 // 0.1011
0.2 x 2 = 0. 4 // 0.10110
0.4 x 2 = 0. 8 // 0.101100
0.8 x 2 = 1. 6 // 0.1011001
....

即小数0.7将表示为0.101100110011001100.....(无限循环),无法用有限位长的浮点数来准确表示,并存在"舍入(Rounding)"现象。

假设在NEAR公链上,需要分发0.7个NEAR代币给十位用户,具体每位用户分得的NEAR代币数量将计算保存于result_0变量中。

#[test]
fn precision_test_float() {
    // 浮点数无法准确的表示整数
    let amount: f64 = 0.7;     // 次变量amount表示0.7个NEAR代币
    let divisor: f64 = 10.0;   // 定义除数
    let result_0 = a / b;     // 执行浮点数的除法运算
    println!("The value of a: {:.20}", a);
    assert_eq!(result_0, 0.07, "");
}

执行该测试用例的输出结果如下:

running 1 test
The value of a: 0.69999999999999995559
thread "tests::precision_test_float" panicked at "assertion failed: (left == right)
 left: 0.06999999999999999, right: 0.07: ", src/lib.rs:185:9

可见在上述浮点运算中,amount的值并非准确地表示了0.7,而是一个极为近似的值0.69999999999999995559。进一步的,对于诸如amount/divisor的单一除法运算,其运算结果也将变为不精确的0.06999999999999999,并非预期的0.07。由此可见浮点数运算的不确定性。

对此,我们不得不考虑在智能合约中使用其它类型的数值表示方法,如定点数。

  1. 根据定点数小数点固定的位置不同,定点数有定点(纯)整数和定点(纯)小数两种。
  2. 小数点固定在数的最低位之后,则称其为定点整数。

在实际的智能合约编写中,通常会使用一个具有固定分母的分数来表示某一数值,例如分数"x/N",其中"N"是常数,"x"可以变化。

若"N"取值为"1,000,000,000,000,000,000",也就是"10^18",此时小数可被表示为整数,像这样:

1.0 ->  1_000_000_000_000_000_000
0.7 ->    700_000_000_000_000_000
3.14 -> 3_140_000_000_000_000_000

在NEAR Protocol中,该N常见的取值为"10^24",即10^24个yoctoNEAR等价于1个NEAR代币。

基于此,我们可以将本小节的单元测试修改为如下方式进行计算:

#[test]
fn precision_test_integer() {
    // 首先定义常数N,表示精度。
    let N: u128 =    1_000_000_000_000_000_000_000_000;  // 即定义 1 NEAR = 10^24 yoctoNEAR
    // 初始化amount,实际此时amount所表示的值为700_000_000_000_000_000 / N = 0.7 NEAR; 
    let amount: u128 = 700_000_000_000_000_000_000_000; // yoctoNEAR
    // 初始化除数divisor
    let divisor: u128 = 10; 
    // 计算可得:result_0 = 70_000_000_000_000_000_000_000 // yoctoNEAR
    // 实际表示 700_000_000_000_000_000_000_000 / N = 0.07 NEAR; 
    let result_0 = amount / divisor;
    assert_eq!(result_0, 70_000_000_000_000_000_000_000, "");
}

以此可获得数值精算的运算结果: 0.7 NEAR / 10 = 0.07 NEAR

running 1 test
test tests::precision_test_integer ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s

2. Rust整数计算精度的问题

从上文第1小节的描述中可以发现,使用整数运算可解决某些运算场景中浮点数运算精度丢失问题。

但这并非意味着使用整数计算的结果完全是准确可靠的。本小节将介绍影响整数计算精度的部分原因。

2.1 运算顺序

同一算数优先级的乘法与除法,其前后顺序的变化可能直接影响到计算结果,导致整数计算精度的问题。

例如存在如下运算:

#[test]
fn precision_test_div_before_mul() {
    let a: u128 = 1_0000;
    let b: u128 = 10_0000;
    let c: u128 = 20;
    // result_0 = a * c / b
    let result_0 = a
        .checked_mul(c)
        .expect("ERR_MUL")
        .checked_div(b)
        .expect("ERR_DIV");
    // result_0 = a / b * c
    let result_1 = a
        .checked_div(b)
        .expect("ERR_DIV")
        .checked_mul(c)
        .expect("ERR_MUL");
    assert_eq!(result_0,result_1,"");
}

执行单元测试的结果如下:

running 1 test
thread "tests::precision_test_0" panicked at "assertion failed: (left == right)
 left: 2, right: 0: ", src/lib.rs:175:9

我们可以发现result_0 = a * c / b及result_1 = (a / b)* c尽管它们的计算公式相同,但是运算结果却不同。

分析具体的原因为:对于整数除法而言,小于除数的精度会被舍弃。因此在计算result_1的过程中,首先计算的(a / b)会率先失去计算精度,变为0;而在计算result_0时,会首先算得a * c的结果20_0000,该结果将大于除数b,因此避免了精度丢失的问题,可得到正确的计算结果。

2.2 过小的数量级

#[test]
fn precision_test_decimals() {
    let a: u128 = 10;
    let b: u128 = 3;
    let c: u128 = 4;
    let decimal: u128 = 100_0000;
    // result_0 = (a / b) * c
    let result_0 = a
        .checked_div(b)
        .expect("ERR_DIV")
        .checked_mul(c)
        .expect("ERR_MUL");
    // result_1 = (a * decimal / b) * c / decimal;  
    let result_1 = a
        .checked_mul(decimal)  // mul decimal
        .expect("ERR_MUL")
        .checked_div(b)
        .expect("ERR_DIV")
        .checked_mul(c)
        .expect("ERR_MUL")
        .checked_div(decimal)  // div decimal 
        .expect("ERR_DIV");
    println!("{}:{}", result_0, result_1);
    assert_eq!(result_0, result_1, "");
}

该单元测试的具体结果如下:

running 1 test
12:13
thread "tests::precision_test_decimals" panicked at "assertion failed: (left == right)
 left: 12, right: 13: ", src/lib.rs:214:9

可见运算过程等价的result_0和result_1运算结果并不相同,且result_1 = 13更加地接近于实际预期的计算值:13.3333....

3. 如何编写数值精算的Rust智能合约

保证正确的精度在智能合约中十分重要。尽管Rust语言中也存在整数运算结果精度丢失的问题,但我们可以采取如下一些防护手段来提高精度,达到令人满意的效果。

3.1 调整运算的操作顺序

  • 令整数乘法优先于整数的除法。

3.2 增加整数的数量级

  • 整数使用更大的数量级,创造更大的分子。

比如对于一个NEAR token来说,如果定义其上文所描述的N = 10,则意味着:若需要表示5.123的NEAR价值,则实际运算所采用的整数数值将表示为5.123* 10^10 = 51_230_000_000。该值继续参与后续的整数运算,可提高运算精度。

3.3 积累运算精度的损失

对于确实无法避免的整数计算精度问题,项目方可以考虑记录累计的运算精度的损失。

假设如下使用fn distribute(amount: u128, offset: u128) -> u128为USER_NUM位用户分发代币的场景。

const USER_NUM: u128 = 3;
fn distribute(amount: u128, offset: u128) -> u128 {
    let token_to_distribute = offset + amount;
    let per_user_share = token_to_distribute / USER_NUM;
    println!("per_user_share {}",per_user_share);
    let recorded_offset = token_to_distribute - per_user_share * USER_NUM;
    recorded_offset
}
#[test]
fn record_offset_test() {
    let mut offset: u128 = 0;
    for i in 1..7 {
        println!("Round {}",i);
        offset = distribute(to_yocto("10"), offset);
        println!("Offset {}\n",offset);
    }
}

在该测试用例中,系统每次将给3位用户分发10个Token。但是,由于整数运算精度的问题,第一轮中计算per_user_share时,获得的整数运算结果为10 / 3 = 3,即第一轮distribute用户将平均获得3个token,总计9个token被分发。

此时可以发现,系统中还剩下1个token未能分发给用户。为此可以考虑将该剩余的token临时保存在系统全局的变量offset中。等待下次系统再次调用distribute给用户分发token时,该值将被取出,并尝试和本轮分发的token金额一起分发给用户。

如下为模拟的代币分发过程:

running 1 test
Round 1
per_user_share 3
Offset1
Round 2
per_user_share 3
Offset 2
Round 3
per_user_share 4
Offset 0
Round 4
per_user_share 3
Offset 1
Round 5
per_user_share 3
TOKEN-4.21%
此页面可能包含第三方内容,仅供参考(非陈述/保证),不应被视为 Gate 认可其观点表述,也不得被视为财务或专业建议。详见声明
  • 赞赏
  • 6
  • 分享
评论
0/400
WalletWhisperervip
· 1小时前
令人着迷的是,Rust 的浮点数可能成为我们下一个脆弱性貔貅盘... 密切关注
查看原文回复0
OnlyOnMainnetvip
· 1小时前
浮点数计算+链上 呵呵吓死我
回复0
逃顶大师vip
· 1小时前
铁子们 这精度问题跟我踩顶一样准啊
回复0
RamenDeFiSurvivorvip
· 2小时前
溜了溜了 这精度问题真闹心
回复0
NFT_考古学家vip
· 2小时前
精度问题才最致命…搞不好血本无归
回复0
matic填坑工vip
· 2小时前
啥时候能写一篇debug合集啊
回复0
交易,随时随地
qrCode
扫码下载 Gate APP
社群列表
简体中文
  • 简体中文
  • English
  • Tiếng Việt
  • 繁體中文
  • Español
  • Русский
  • Français (Afrique)
  • Português (Portugal)
  • Bahasa Indonesia
  • 日本語
  • بالعربية
  • Українська
  • Português (Brasil)