Точні числові обчислення в смартконтрактах Rust: цілі числа проти чисел з плаваючою комою

Rust смартконтракти养成日記(7): числовий розрахунок

Огляд попередніх випусків:

  • Щоденник розвитку смартконтрактів Rust (1) Визначення даних стану контракту та реалізація методів
  • Щоденник розвитку смартконтрактів на Rust (2) Написання юніт-тестів смартконтрактів на Rust
  • Щоденник розвитку смартконтрактів Rust(3)Розгортання смартконтрактів Rust, виклик функцій та використання Explorer
  • Rust смартконтракти виховання щоденник ( 4) Rust смартконтракти переповнення цілих чисел
  • Rust смартконтракти养成日记(5)重入攻击
  • Щоденник розвитку смартконтрактів Rust (6) атака відмови в обслуговуванні

1. Проблема точності обчислень з плаваючою комою

На відміну від звичайних смартконтрактів програмування мовою Solidity, мова Rust нативно підтримує операції з плаваючою комою. Однак, операції з плаваючою комою мають невідворотні проблеми з точністю обчислень. Тому, при написанні смартконтрактів, не рекомендується використовувати операції з плаваючою комою (, особливо при обробці співвідношень або відсоткових ставок, що стосуються важливих економічних/фінансових рішень ).

Наразі основні мови програмування для представлення чисел з плаваючою комою в основному дотримуються стандарту IEEE 754, мова Rust не є винятком. Нижче наведено опис типу з плаваючою комою подвійної точності f64 в мові Rust та форма зберігання двійкових даних в комп'ютері:

Числа з плаваючою комою використовують наукову нотацію з основою 2 для вираження. Наприклад, десяткове число 0.8125 можна представити за допомогою обмеженої кількості двійкових знаків 0.1101, конкретний спосіб перетворення наведено нижче:

0.8125 * 2 = 1 .625 // 0.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 х 2 = 1. 4 // 0.1
0,4 х 2 = 0. 8 // 0.10
0,8 х 2 = 1. 6 // 0.101
0,6 х 2 = 1. 2 // 0.1011
0,2 х 2 = 0. 4 // 0.10110
0,4 х 2 = 0. 8 // 0.101100
0,8 х 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!("Значення a: {:.20}", a);
    assert_eq!(result_0, 0,07, ");
}

Результати виконання цього тестового випадку наведені нижче:

виконання 1 тесту
Значення a: 0.69999999999999995559
потік "tests::precision_test_float" вийшов з ладу з повідомленням "перевірка не вдалася: (left == right)
 ліворуч: 0.06999999999999999, праворуч: 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.

На основі цього ми можемо змінити модульні тести цього підрозділу для обчислення наступним чином:

#)
fn precision_test_integer[test]( {
    // По-перше, визначимо константу 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; 
    Нехай кількість: U128 = 700_000_000_000_000_000; yoctoNEAR
    // Ініціалізація дільника 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; 
    нехай result_0 = сума / дільник;
    assert_eq!)result_0, 70_000_000_000_000_000_000, ""(;
}

З цього можна отримати результат чисельного обчислення: 0.7 NEAR / 10 = 0.07 NEAR

запуск 1 тесту
тести тестують::прецизійний_тест_ціле ... ок
тестовий результат: ок. 1 пройдено; 0 провалено; 0 ігноровано; 0 виміряно; 8 відфільтровано; закінчено за 0.00с

! [])https://img-cdn.gateio.im/webp-social/moments-7bdd27c1211e1cc345bf262666a993da.webp(

2. Проблема з точністю обчислень з цілими числами в Rust

З опису в пункті 1 вище можна помітити, що використання цілочисельних обчислень може вирішити проблему втрати точності обчислень з плаваючою комою в деяких сценаріях обчислень.

Але це не означає, що результати обчислень з цілими числами є абсолютно точними та надійними. У цьому підрозділі будуть розглянуті деякі причини, які впливають на точність обчислень з цілими числами.

) 2.1 порядок виконання операцій

Зміна порядку множення та ділення з однаковим пріоритетом арифметичних дій може безпосередньо вплинути на результат обчислення, що призводить до проблеми точності обчислень з цілими числами.

Наприклад, існує наступна операція:

####
fn precision_test_div_before_mul[test]( {
    Нехай a: u128 = 1_0000;
    нехай b: u128 = 10_0000;
    Нехай С: U128 = 20;
    result_0 = а * с / б
    Нехай result_0 = a
        .checked_mul)c(
        .expect)"ERR_MUL"(
        .checked_div)b(
        .expect)"ERR_DIV"(;
    result_0 = а / б * с
    Нехай result_1 = a
        .checked_div)b(
        .expect)"ERR_DIV"(
        .checked_mul)c(
        .expect)"ERR_MUL"(;
    assert_eq!)result_0,result_1,"(;
}

Результати виконання модульних тестів такі:

запуск 1 тесту
потік "tests::precision_test_0" панікував на "перевірка не пройшла: )left == right(
 ліворуч: 2, праворуч: 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 занадто малий порядок величини

####
fn precision_test_decimals[test]( {
    Нехай А: U128 = 10;
    нехай b: u128 = 3;
    Нехай c: u128 = 4;
    Нехай десятковий дріб: u128 = 100_0000;
    result_0 = )a / b( * с
    Нехай result_0 = a
        .checked_div)b(
        .expect)"ERR_DIV"(
        .checked_mul)c(
        .expect)"ERR_MUL"(;
    // result_1 = )a * десятковий / b( * c / десятковий;  
    Нехай result_1 = a
        .checked_mul)decimal( // мул десятковий дріб
        .expect)"ERR_MUL"(
        .checked_div)b(
        .expect)"ERR_DIV"(
        .checked_mul)c(
        .expect)"ERR_MUL"(
        .checked_div)decimal( // div десятковий дріб 
        .expect)"ERR_DIV"(;
    println!)"{}:{}", result_0, result_1(;
    assert_eq!)result_0, result_1, "(;
}

Конкретні результати цього модульного тесту такі:

запуск 1 тесту
12:13
потік "тести::precision_test_decimals" панікував з причини "помилка перевірки: )left == right(
 ліворуч: 12, праворуч: 13: ", src/lib.rs:214:9

Як видно, результати обчислень result_0 і result_1, які є еквівалентними в обчислювальному процесі, не однакові, і result_1 = 13 набагато ближче до фактичного очікуваного значення обчислення: 13.3333....

! [])https://img-cdn.gateio.im/webp-social/moments-1933a4a2dd723a847f0059d31d1780d1.webp(

3. Як написати Rust смартконтракти для числового актуарію

Гарантування правильної точності в смартконтракти є дуже важливим. Хоча в мові Rust також існує проблема втрати точності при цілочисельних операціях, ми можемо вжити такі захисні заходи для покращення точності та досягнення задовільного результату.

) 3.1 Налаштування порядку виконання операцій

  • Зробіть множення цілих чисел пріоритетом над діленням цілих чисел.

3.2 збільшити порядок числа

  • Цілі числа використовують більші порядки, створюючи більші чисельники.

Наприклад, для токена NEAR, якщо визначити N, як описано вище, рівним 10, це означає: якщо потрібно виразити вартість NEAR 5.123, то фактичне значення, яке використовується в обчисленнях, буде виражено як 5.123 * 10^10 = 51_230_000_000. Це значення продовжуватиме брати участь у наступних цілочисельних обчисленнях, що може підвищити точність розрахунків.

3.3 Втрата точності при накопленні обчислень

Щодо дійсно невідворотних проблем з точністю обчислень з цілими числами, команда проекту може розглянути можливість фіксації накопичених втрат точності обчислень.

u128 використовується для розподілу токенів серед USER_NUM користувачів.

const USER_NUM: u128 = 3;
FN distribute###amount: U128, зміщення: u128( -> u128 {
    Нехай token_to_distribute = зсув + сума;
    Нехай per_user_share = token_to_distribute / USER_NUM;
    println!)"per_user_share {}",per_user_share(;
    нехай recorded_offset = token_to_distribute - per_user_share * USER_NUM;
    записаний_зсув
}
#)
fn record_offset_test() {
    нехай mut зміщення: u128 = 0;
    для i в 1..7 {
        println![test]"Round {}",i(;
        зміщення = distribute)to_yocto("10"), offset(;
        println!("Offset {}\n",offset);
    }
}

У цьому тестовому випадку система кожного разу розподіляє 10 токенів трьом користувачам. Однак через проблеми з точністю цілочисельних обчислень, під час першого раунду при обчисленні per_user_share отриманий результат цілочисельних обчислень дорівнює 10 / 3 = 3, тобто у першому раунді користувачі отримають в середньому 3 токени, всього буде розподілено 9 токенів.

У цей момент можна помітити, що в системі залишився 1 токен, який не був розподілений користувачеві. Для цього можна розглянути можливість тимчасово зберегти цей залишковий токен у глобальній змінній системи offset. Чекаючи наступного виклику системи distribute для розподілу токенів користувачеві, це значення буде витягнуте та спробують розподілити його разом з сумою токенів, що розподіляються в цьому раунді.

Ось імітований процес розподілу токенів:

виконання 1 тесту
Раунд 1
per_user_share 3
Зсув1
Раунд 2
per_user_share 3
Зміщення 2
Раунд 3
per_user_share 4
Зсув 0
Раунд 4
per_user_share 3
Зміщення 1
Раунд 5
per_user_share 3
TOKEN1.69%
Переглянути оригінал
Ця сторінка може містити контент третіх осіб, який надається виключно в інформаційних цілях (не в якості запевнень/гарантій) і не повинен розглядатися як схвалення його поглядів компанією Gate, а також як фінансова або професійна консультація. Див. Застереження для отримання детальної інформації.
  • Нагородити
  • 6
  • Поділіться
Прокоментувати
0/400
WalletWhisperervip
· 5год тому
цікаво, як плаваючі точки rust можуть стати нашою наступною вразливістю honeypot... уважно спостерігаю
Переглянути оригіналвідповісти на0
OnlyOnMainnetvip
· 5год тому
Вирахування з плаваючою комою+у блокчейні Хе-хе, налякав мене
Переглянути оригіналвідповісти на0
TopEscapeArtistvip
· 5год тому
Товариші, ця проблема з точністю така ж точна, як і те, що я стою на вершині.
Переглянути оригіналвідповісти на0
RamenDeFiSurvivorvip
· 5год тому
Пішов, пішов. Ця проблема з точністю справді дратує.
Переглянути оригіналвідповісти на0
NFTArchaeologistvip
· 5год тому
Проблема точності є найсмертельнішою... якщо не впораєшся, залишишся без грошей.
Переглянути оригіналвідповісти на0
MaticHoleFillervip
· 5год тому
Коли можна написати збірку для налагодження?
Переглянути оригіналвідповісти на0
  • Закріпити