К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Целочисленная математика и округление в смарт-контрактах

Почему в Solidity нет дробей, как работают базисные пункты, порядок умножения и деления, проверка переполнения, mulDiv и расчёт долей вычитанием.

4 мин чтенияОбновлено:
В этой статье

В обычной программе 0.1 + 0.2 ломает голову, а проценты считаются «как получится». В смарт-контракте за каждую единицу точности отвечают реальные деньги, поэтому все вычисления ведутся целыми числами, а округление - сознательное решение. Эта статья показывает, как не потерять ни копейки. Для контекста о числах в Ethereum - аккаунты и состояние.

Нет дробей: всё в целых

В EVM нет типов float или double. Деньги хранятся в минимальных единицах токена. Если у токена decimals = 6 (как USDC), то 1,5 USDC - это 1500000. Для ETH 1 ETH = 10^18 wei (о токенах).

Проценты задают базисными пунктами (bps): 1 bps = 0,01%, 10 000 bps = 100%. Ставка 1% - это 100 bps, 2,5% - 250 bps. Так видно, как в контракте эскроу:

solidity
uint16 public constant BPS = 10_000;
uint16 public constant PLATFORM_FEE_BPS = 100; // 1%

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

Целочисленное деление отбрасывает остаток: 7 / 2 == 3, 1 / 3 == 0. Расчёт комиссии:

solidity
platformFee = (amount * PLATFORM_FEE_BPS) / BPS;

Для суммы 99 единиц: 99 * 100 / 10000 = 0 (комиссия 0,99 округляется до 0). Это не ошибка, а следствие дискретности. Для токена с 6 знаками потери в минимальной единице несущественны, но их нужно осознавать.

Умножай сначала, дели потом

Порядок операций критичен. Возьмём сумму 99 999 и ставку 150 bps (1,5%):

solidity
// плохо: сначала деление теряет точность
uint256 bad = (amount / BPS) * fee;   // (99999 / 10000) = 9;  9 * 150 = 1350

// хорошо: умножение сохраняет точность
uint256 good = (amount * fee) / BPS;  // 99999 * 150 = 14999850;  / 10000 = 1499

Разница - 149 единиц из 1 499, то есть около 10% комиссии потеряно из-за преждевременного деления. Правило: сначала умножение, потом деление.

Переполнение

Начиная с Solidity 0.8 арифметика проверяется: если результат не помещается в тип, транзакция откатывается с ошибкой Panic(0x11). Это защищает от класса уязвимостей, когда число «переходило через ноль» (в версиях до 0.8 так и было). Блок unchecked { ... } отключает проверки ради экономии газа и подходит, только если вы доказали, что переполнение невозможно.

Но есть исключение: явное приведение типов не проверяется.

solidity
uint256 big = 2**100;
uint96 small = uint96(big); // тихо обрежется, без ошибки

В контракте эскроу сумма хранится в uint96 вместе с адресом в одном слове хранилища. Поэтому перед записью сумма явно проверяется, и при превышении вызов отклоняется с ошибкой AmountTooLarge. Без этой проверки продавец мог бы создать «оферту» с обрезанной суммой.

Деление на ноль

Деление на ноль вызывает Panic(0x12). Надо следить за знаменателями, особенно если они берутся из внешних данных (например, из баланса пула).

Масштабируемое умножение: mulDiv

Выражение a * b / c может переполниться на промежуточном шаге a * b, даже если итог мал. Библиотечная функция mulDiv(a, b, c) считает с 512-битным промежуточным значением и избегает этой проблемы. Её используют в DeFi-протоколах для точных расчётов.

Направление округления

Если нужно разделить сумму, деление на части теряет остаток. Куда он пойдёт, решает разработчик, и это вопрос безопасности и справедливости. Общее правило: округляйте в пользу протокола, чтобы нельзя было «выжать» пыль многократными операциями.

В эскроу выбран иной, более надёжный подход - вычитание:

solidity
platformFee  = (amount * PLATFORM_FEE_BPS) / BPS;
uint256 net  = amount - platformFee;

arbiterFee   = (net * arbiterFeeBps) / BPS;
uint256 pot  = net - arbiterFee;

buyerAmount  = (pot * buyerShareBps) / BPS;
// Остаток вычисляется вычитанием: округление вниз не создаст нехватки.
sellerAmount = pot - buyerAmount;

Только комиссии и доля покупателя считаются делением. Доля продавца - остаток. Поэтому гарантируется равенство:

text
platformFee + arbiterFee + buyerAmount + sellerAmount == amount

Ни одна единица токена не теряется и не создаётся. Если бы обе доли считались по формуле pot * share / BPS, они могли бы в сумме дать на единицу меньше pot, и эта «пыль» застряла бы в контракте. Расчёты на конкретных числах: комиссии на примерах.

Проверка инвариантов фаззингом

Как убедиться, что суммы всегда сходятся? Написать тест-фаззер: подставить тысячи случайных значений и проверить инвариант «части равны целому» (как тестируют контракты, руководство Foundry). Такие тесты находят ошибки в граничных случаях: сумма 1, максимально возможная сумма, доли 0 и 10 000.

Чек-лист для разработчика

  1. Деньги - только uint256 в минимальных единицах.
  2. Проценты - в bps, умножение до деления.
  3. Явные приведения типов - только после проверки диапазона (или SafeCast).
  4. Для сложных формул - mulDiv.
  5. Остаток - вычитанием.
  6. Инвариант «сумма частей = целое» - фаззингом.
  7. Помните про разные decimals у токенов (особые токены).

Что почитать дальше

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

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.