Переполнение целых: SafeMath и Solidity 0.8

Почему «число + 1» когда-то могло обнулить баланс — и почему теперь нет.

Переполнение (overflow/underflow) — выход результата арифметики за пределы диапазона типа: при превышении максимума значение «заворачивается» к минимуму и наоборот.

Откуда берётся проблема

Целые в EVM имеют фиксированный размер, например uint256 — от 0 до 2²⁵⁶−1. До Solidity 0.8 арифметика была «обёрточной»: если из нуля вычесть единицу (0 - 1), результат не уходил в минус, а становился максимальным числом. В контексте денег это катастрофа: баланс 0, из которого «списали» больше, превращался в гигантскую сумму. Атакующий, подведя баланс под underflow, мог «нарисовать» себе колоссальные средства.

# Иллюстрация обёрточной арифметики на 8 битах (0..255)
MAX = 255

def wrap(x):
    return x % (MAX + 1)

print("255 + 1 ->", wrap(255 + 1))   # переполнение вверх
print("0 - 1   ->", wrap(0 - 1))     # переполнение вниз (underflow)

Вывод:

255 + 1 -> 0
0 - 1   -> 255

На 256 битах эффект тот же, только числа огромные: «0 − 1» даёт максимальный uint256 — астрономический фейковый баланс.

Историческое решение: SafeMath

До 0.8 разработчики оборачивали все операции в библиотеку SafeMath, которая после каждого сложения/вычитания проверяла, не произошло ли переполнение, и откатывала транзакцию, если да. Это работало, но засоряло код (a.add(b) вместо a + b) и иногда забывалось — а забытая операция оставалась уязвимой.

// До 0.8: ручная защита через SafeMath
using SafeMath for uint256;
balances[to] = balances[to].add(amount);   // .add откатит при overflow

Как работает под капотом: Solidity 0.8+

Начиная с версии 0.8, компилятор Solidity встроил проверки переполнения по умолчанию: обычные +, -, * сами откатывают транзакцию при выходе за диапазон. SafeMath больше не нужен для базовой защиты. Там, где обёртка действительно желательна (намеренная цикличность счётчиков, оптимизация газа в безопасном месте), её включают явным блоком unchecked { ... } — это сигнал «я сознательно отключил проверку здесь».

// Solidity 0.8+: проверки встроены
balances[to] += amount;        // авто-revert при overflow

unchecked {
    counter += 1;              // намеренно без проверки (осознанно!)
}

Частые ошибки

  • Бездумный unchecked. Внутри него возвращается старое опасное поведение; используйте только там, где переполнение математически невозможно.
  • Старый pragma <0.8 без SafeMath. Сочетание старой версии и «голой» арифметики — уязвимость.
  • Приведение типов с обрезанием. uint256uint64 теряет старшие биты — отдельный источник ошибок.

Итоги

  • Целые фиксированного размера «заворачиваются» при переполнении — для денег это критично.
  • До Solidity 0.8 защищались библиотекой SafeMath (легко забыть).
  • С 0.8 проверки переполнения встроены в компилятор по умолчанию.
  • unchecked отключает их — применяйте только осознанно и обоснованно.
Проверьте себя
1. Что происходит при underflow uint без защиты (например, 0 − 1)?
AТранзакция всегда откатывается
BЗначение становится отрицательным
CЗначение «заворачивается» к максимуму типа
DКонтракт удаляется
2. Зачем до Solidity 0.8 использовали SafeMath?
AДля ускорения
BЧтобы проверять переполнение и откатывать опасные операции
CДля шифрования
DДля контроля доступа
3. Что изменилось в Solidity 0.8?
AАрифметика стала медленнее
BПроверки переполнения встроены по умолчанию, а отключаются блоком unchecked
CSafeMath стал обязателен
DЦелые стали безразмерными