НОВОЕ: Helius приобретает Light Protocol
арифметика в Solana — как выполнять математические операции в программах или смарт-контрактах Solana
Блог/Основы

Арифметика в Solana: лучшие практики разработки финансовых приложений

Обучение разработчиковMike MacCana в XMike MacCana в LinkedIn
Чтение: 9 мин

Благодарим 0xIchigo и Lostin за редактуру и вклад в эту статью.

Solana — конкурентная среда. Разрабатываете ли вы новый кредитный протокол, агрегатор, рынок прогнозов, токенизацию RWA или что-то еще, у вас может возникнуть желание как можно скорее запуститься в mainnet.

Однако важно помнить: блокчейн-приложения управляют ценностями, поэтому по своей сути являются финансовыми приложениями.

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

Существует множество отличных материалов по программированию для Solana: как проверять, какие аккаунты должны подписывать каждую инструкцию, каким программам принадлежат используемые аккаунты, как предотвращать атаки с повторным открытием аккаунта и так далее. Ограничения аккаунтов в Anchor упрощают реализацию многих таких проверок, а Rust предлагает разумные настройки по умолчанию, например обнаруживает целочисленные переполнения сверху и снизу в режиме Debug.

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

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

В этой статье мы рассмотрим:

  • Использование целых чисел и минимальных единиц
  • Предотвращение потери точности: сначала умножение, затем деление
  • Согласованную политику округления
  • Расчет процентов без чисел с плавающей точкой

Используйте целые числа и минимальные единицы

Недостаточная точность — распространенная уязвимость смарт-контрактов. Неточные математические операции могут создавать уязвимости. Чтобы безопасно выполнять математические операции в приложениях Solana, необходимо использовать целые числа и минимальные единицы.

Рассмотрим простой пример.

В традиционных финансах все операции, которые вы когда-либо выполняли «в USD», фактически проводились в центах. При использовании GBP они проводились в пенсах. 

Аналогично, транзакции в SOL следует обрабатывать в лампортах, а транзакции в USDC — в миллионных долях USDC.

Центы, пенсы и лампорты — это разновидности минимальных единиц (также называемых базовыми единицами). Причина использовать их для всех операций проста: компьютеры не умеют точно обрабатывать числа с плавающей точкой.

Вот единица в двоичном виде:

Тридцать дваШестнадцатьВосемьЧетыреДваОдин
000001

Вот девять в двоичном виде:

Тридцать дваШестнадцатьВосемьЧетыреДваОдин
001001

Но как представить, например, 0,3 USDC? 

Правильный ответ: никак. 

Код
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);

Получим следующий результат:

Программа вывела: "0.1 + 0.2 = 0.30000000000000004"

Вместо этого рассматривайте доллары, GBP, SOL, USDC и любую другую «валюту» как целое количество ее минимальных единиц.

Например, чтобы сложить 0,1 и 0,2 USDC:

Код
let answer_ints: u128 = 100000 + 200000;
msg!("100000 + 200000 = {}", answer_ints);

Получим следующий результат:

Программа вывела: "100000 + 200000 = 300000"

При использовании минимальных единиц потери не возникает.

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

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

Проценты

Проценты следует представлять целыми числами. Обычно для этого используют базисные пункты, которые обозначаются как bps.

Например, 4,74% — это 474 bps.

Сложные проценты

Сложные проценты не следует рассчитывать с помощью числа e (числа Эйлера), которое представляет предел сложных процентов при стремлении частоты начисления к бесконечности.

Использование e позволяет выполнять «непрерывное начисление», то есть представить сложные проценты плавной кривой. Однако в Rust само число e представлено как число с плавающей точкой.

При использовании e вместо других механизмов вы получите и ошибки округления, и другой результат.

Далее в статье мы продемонстрируем оба эффекта.

Переполнения сверху и снизу

Rust хранит целые числа в переменных фиксированного размера. Это означает, что в зависимости от того, является переменная знаковой или беззнаковой, она может занимать только определенный объем памяти.

Например, тип u8 может хранить любое значение от 0 до 255. Если сохранить значение вне этого диапазона, произойдет переполнение сверху или снизу.

Что такое переполнение сверху?

Переполнение сверху происходит, когда значение превышает максимальную вместимость типа переменной и циклически переходит к минимальному значению.

Например, если попытаться сохранить 256 в u8, значение перейдет к 0. Значение 257 перейдет к 1, 258 — к 2, 511 — к 255, а 512 — снова к 0.

Что такое переполнение снизу?

Переполнение снизу происходит, когда значение оказывается ниже минимально допустимого и циклически переходит к максимальному. Для u8 значение -1 перейдет к 255, -2 — к 254 и так далее. Иными словами, переполнение снизу похоже на переполнение сверху, но происходит в обратном направлении.

В Rust есть ряд проверок, которые при переполнении сверху или снизу вызывают панику программы во время выполнения. Однако при компиляции в режиме release эти проверки не включаются. А инструментарий, используемый в среде разработки Solana, по умолчанию компилирует программы Solana в режиме release. 

Чтобы подробнее узнать о переполнениях сверху и снизу и способах их предотвращения, прочитайте наше руководство по безопасности программ Solana.

Сначала умножайте, затем делите

Часто кажется естественным сначала выполнить деление, а затем умножение. Рассмотрим пример разработки рынка прогнозов. Когда пользователь делает ставку на победивший исход, необходимо рассчитать его выигрыш. Выплаты победителям на рынках прогнозов зависят от их доли в общем объеме ставок на победивший исход. Интуитивно это можно представить так:

призовой пул ÷ общая сумма ставок на победивший исход × сумма ставки 

Это кажется вполне естественным.

Сначала мы делим призовой пул на небольшие части, каждая из которых представляет одну «долю» выигрыша, а затем определяем, сколько долей получит каждый участник.

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

призовой пул × сумма ставки ÷ общая сумма ставок на победивший исход

Пример рынка прогнозов

Рассмотрим короткий пример. Если бы вычисления выполнял человек, а не компьютер, правильным ответом было бы 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 минимальной единицы. 

Этот принцип можно сформулировать шире: «сначала выполняйте операции, увеличивающие порядок величины». 

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

Применяйте согласованную политику округления

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

Как отметил Will Thieme из Orca в своем докладе на Breakpoint о типичных проблемах в программах Solana, получение одного токена не кажется значительным.

Однако в Solana выполняются реальные — в понимании традиционных финансов — транзакции с несколькими инструкциями и низкими комиссиями. Поэтому транзакцию можно заполнить множеством отдельных инструкций, которые эксплуатируют ошибки на единицу, а затем выполнить ее с минимальными затратами.

Ошибки округления неизбежны, но ими можно управлять с помощью согласованной политики округления. Заранее решите, будете ли вы округлять вверх или вниз и как обрабатывать половинные значения, то есть 0,5. Округление половины вверх применяется чаще и предписано некоторыми финансовыми стандартами. Главное — последовательно применять выбранную политику во всем коде.

Особенности округления в Rust

Разработчикам следует знать о нескольких потенциально проблемных функциях Rust, поскольку при округлении часто теряется точность. Выбор метода округления может существенно повлиять на точность и поведение программы.

Округление против округления вниз

Например, функция try_round_u64() округляет до ближайшего целого числа. Если программа преобразует обеспечение в ликвидность, округление вверх может привести к выпуску большего количества токенов ликвидности, чем позволяет предоставленное обеспечение. Вместо этого следует использовать функцию try_floor_u64(), чтобы округлить значение вниз до ближайшего целого числа.

Арифметические функции с насыщением

Кроме того, разработчики часто используют арифметические функции saturating_*, например saturating_add, чтобы ограничить значения соответствующими максимумами и минимумами и предотвратить переполнения сверху и снизу. Однако эти функции могут приводить к малозаметной потере точности.

Например, если функция умножает сумму транзакции на множитель вознаграждения и результат превышает максимальное значение типа переменной, пользователь получит меньшее вознаграждение, чем должен.

Это особенно важно учитывать, поскольку ваша программа должна использовать арифметику с фиксированной точкой.

Рассчитывайте проценты без чисел с плавающей точкой

Для расчета сложных процентов часто используют число Эйлера e, но e — это число с плавающей точкой! Не используйте числа с плавающей точкой для расчета процентов. Вместо этого применяйте арифметику с фиксированной точкой.

В библиотеке Solana spl-math есть PreciseNumber. Он работает в более ограниченной среде Rust в Solana и может представлять очень малые десятичные дроби — до 12 знаков после запятой — с сохранением точности.

Код
use spl_math::precise_number::PreciseNumber;

fn calculate_compound_interest(
   principal: u128,
   rate_basis_points: u128,
   time: u128,
   compounds_per_year: u128,
) -> u128 {
   // Formula: result = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
   // Where rate_basis_points is expressed in basis points (500 for 5%)

   // Convert principal to PreciseNumber
   let principal = PreciseNumber::new(principal).unwrap();

   // Convert basis points to decimal percentage (divide by 10000)
   let rate = PreciseNumber::new(rate_basis_points)
       .unwrap()
       .checked_div(&PreciseNumber::new(10_000).unwrap())
       .unwrap();

   // Calculate rate/compounds_per_year
   let rate_per_period = rate
       .checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
       .unwrap();

   // Calculate 'base', which is 1 + rate/compounds_per_year
   let one = PreciseNumber::new(1).unwrap();
   let base = rate_per_period.checked_add(&one).unwrap();

   // Calculate 'total_periods', which is compounds_per_year * time
   let total_periods = compounds_per_year.checked_mul(time).unwrap();

   // Calculate compound_factor, which is (1 + rate/compounds_per_year)^(compounds_per_year * time)
   let compound_factor = base.checked_pow(total_periods).unwrap();

   // Calculate result = principal * compound_factor
   principal
       .checked_mul(&compound_factor)
       .unwrap()
       .to_imprecise()
       .unwrap()
}

Разницу можно увидеть, запустив код:

  • Программа вывела: "Использование spl-math PreciseNumber"
  • Программа вывела: "Инвестиция $1000 под 5% на 5 лет с начислением 1 раз в год:"
  • Программа вывела: "Итоговая сумма: $1276"

Для сравнения, при использовании e:

  • Программа вывела: " Использование e (не делайте так)"
  • Программа вывела: "Инвестиция $1000 под 5% на 5 лет с начислением 1 раз в год:"
  • Программа вывела: "Итоговая сумма: $1284"

Заключение

Математика токенов — скучная тема, но невнимательность к ней может привести к проблемам, которые вам точно не нужны. Ваши ончейн-приложения должны обрабатывать токены с тем уровнем точности и безопасности, которого требуют пользователи.

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

Освоив эти основы арифметики, перед запуском в mainnet поручите проверку своего кода сторонней компании, которая специализируется на аудите Solana.

Дополнительные материалы

Подпишитесь на Helius

Следите за новостями разработки на Solana и получайте уведомления о наших публикациях