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
TOKEN2.49%
查看原文
此頁面可能包含第三方內容,僅供參考(非陳述或保證),不應被視為 Gate 認可其觀點表述,也不得被視為財務或專業建議。詳見聲明
  • 讚賞
  • 6
  • 分享
留言
0/400
WalletWhisperervip
· 6小時前
令人着迷的是,Rust 的浮点数可能成为我们下一个脆弱性貔貅盘... 密切关注
查看原文回復0
OnlyOnMainnetvip
· 6小時前
浮点数计算+链上 呵呵吓死我
回復0
逃顶大师vip
· 6小時前
铁子们 这精度问题跟我踩顶一样准啊
回復0
RamenDeFiSurvivorvip
· 7小時前
溜了溜了 这精度问题真闹心
回復0
NFT_考古学家vip
· 7小時前
精度问题才最致命…搞不好血本无归
回復0
matic填坑工vip
· 7小時前
啥时候能写一篇debug合集啊
回復0
交易,隨時隨地
qrCode
掃碼下載 Gate APP
社群列表
繁體中文
  • 简体中文
  • English
  • Tiếng Việt
  • 繁體中文
  • Español
  • Русский
  • Français (Afrique)
  • Português (Portugal)
  • Bahasa Indonesia
  • 日本語
  • بالعربية
  • Українська
  • Português (Brasil)