Точное численное вычисление в смарт-контрактах 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.0699999999999999, справа: 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; 
    let amount: u128 = 700_000_000_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_000_000, ""(;
}

С помощью этого можно получить числовой расчет: 0,7 NEAR / 10 = 0,07 NEAR

запускается 1 тест
тест тесты::precision_test_integer ... ок
результат теста: хорошо. 1 пройден; 0 провален; 0 проигнорировано; 0 измерено; 8 отфильтровано; завершено за 0.00 с

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

2. Проблема точности вычислений с целыми числами в Rust

Из описания в первом пункте выше можно заметить, что использование целочисленных операций может решить проблему потери точности операций с плавающей запятой в некоторых сценариях.

Но это не означает, что результаты, полученные с использованием целочисленных вычислений, полностью точны и надежны. В этом разделе будут рассмотрены некоторые причины, влияющие на точность целочисленных вычислений.

) 2.1 Порядок операций

Изменение порядка выполнения операций умножения и деления с одинаковым приоритетом может напрямую повлиять на результат вычислений, что приведет к проблемам с точностью целочисленных расчетов.

Например, существует следующая операция:

####
fn precision_test_div_before_mul[test]( {
    пусть a: u128 = 1_0000;
    пусть b: u128 = 10_0000;
    пусть c: u128 = 20;
    result_0 = а * c / b
    Пусть result_0 = a
        .checked_mul)c(
        .expect)"ERR_MUL"(
        .checked_div)b(
        .expect)"ERR_DIV"(;
    result_0 = a / b * c
    Пусть 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]( {
    пусть a: u128 = 10;
    пусть b: u128 = 3;
    пусть c: u128 = 4;
    пусть десятичная: u128 = 100_0000;
    result_0 = )a / b( * c
    Пусть 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
поток "tests::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 пользователей.

константа 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;
    recorded_offset
}
#)
FN record_offset_test() {
    let mut offset: u128 = 0;
    для i в 1..7 {
        println![test]"Round {}",i(;
        offset = distribute)to_yocto("10"), offset(;
        println!("Смещение {}\n",offset);
    }
}

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

В данный момент можно заметить, что в системе еще остался 1 токен, который не был распределен пользователю. В связи с этим можно рассмотреть возможность временного сохранения оставшегося токена в глобальной переменной системы offset. При следующем вызове системы для распределения токенов пользователю это значение будет извлечено и попытается быть распределено вместе с количеством токенов, распределяемых в этом раунде.

Ниже представлен смоделированный процесс распределения токенов:

запуск 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
TOKEN5.57%
Посмотреть Оригинал
На этой странице может содержаться сторонний контент, который предоставляется исключительно в информационных целях (не в качестве заявлений/гарантий) и не должен рассматриваться как поддержка взглядов компании Gate или как финансовый или профессиональный совет. Подробности смотрите в разделе «Отказ от ответственности» .
  • Награда
  • 6
  • Поделиться
комментарий
0/400
WalletWhisperervip
· 08-06 11:51
удивительно, как плавающая запятая rust может стать нашей следующей уязвимостью приманкой... внимательно следим
Посмотреть ОригиналОтветить0
OnlyOnMainnetvip
· 08-06 11:50
Вычисление с плавающей запятой + в блокчейне Хе-хе, напугал меня
Посмотреть ОригиналОтветить0
TopEscapeArtistvip
· 08-06 11:45
Ребята, эта проблема с точностью такая же точная, как и то, что я наступаю на вершину.
Посмотреть ОригиналОтветить0
RamenDeFiSurvivorvip
· 08-06 11:24
Убегаю, убегаю. Эта проблема с точностью действительно беспокоит.
Посмотреть ОригиналОтветить0
NFTArchaeologistvip
· 08-06 11:23
Проблемы с точностью являются наиболее смертельными... если не повезет, можно потерять все.
Посмотреть ОригиналОтветить0
MaticHoleFillervip
· 08-06 11:21
Когда можно написать коллекцию отладок?
Посмотреть ОригиналОтветить0
  • Закрепить