Готовый ERC20: OpenZeppelin и что он делает за вас
- Зачем это нужно
- Одна строка импорта и одно слово is
- Библиотека оказалась меньше рукописного токена
- Что изменилось в версии 5
- _update — единственная дверь
- Расширения
- Безопасный перевод чужого токена
- На площадке интернета нет
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Контракт, который выдают на аудит в модуле В, начинается ровно с этого:
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Token are ERC20("CryptoMonster", "CMON") { … }
Опечатку в третьей строке мы уже разбирали. А вот вторую строку — подключение библиотеки — нет. Чтобы прочитать этот контракт, надо знать, что именно приезжает из библиотеки и чего в нём поэтому нет: все балансы, переводы, разрешения и события лежат там.
Библиотека называется OpenZeppelin, актуальная версия — 5.6.1. Большинство уроков в интернете описывают версию 4.x, а между ними сломанная совместимость: конструкторы, тексты ошибок и точки расширения изменились. Разберём и то, и другое.
Одна строка импорта и одно слово is
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract OzToken is ERC20 {
constructor(uint256 supply) ERC20("Oz", "OZT") {
_mint(msg.sender, supply);
}
}
Всё, что мы писали руками в прошлой главе, здесь приезжает готовым: шесть обязательных функций, три поля, два события, а также внутренние _transfer, _mint, _burn, _approve.
Библиотека оказалась меньше рукописного токена
Первая модель ставит рядом три контракта — токен из прошлой главы, тот же токен со своими ошибками вместо строк, и наследника библиотеки:
── один и тот же токен: руками против готовой библиотеки ──
байткод MiniToken: 3976 байт
байткод MiniErrToken: 3733 байт (тот же код, но кастомные ошибки вместо строк)
байткод OzToken: 3670 байт
газ развёртывания MiniToken: 1019899
газ развёртывания OzToken: 966009
перевод на пустой баланс: MiniToken 52379 | OzToken 52150
перевод повторно: MiniToken 35279 | OzToken 35050
── текст отката: у anna на балансе 200, она отправляет 500 ──
MiniToken: reverted with reason string 'Недостаточно средств'
MiniErrToken: reverted with custom error 'NotEnough("0x7099…79C8", 200, 500)'
OzToken: reverted with custom error 'ERC20InsufficientBalance("0x7099…79C8", 200, 500)'
── что готовый ERC20 проверяет сам ──
decimals(): 18
перевод на address(0): 'ERC20InvalidReceiver("0x0000…0000")'
transferFrom без approve: 'ERC20InsufficientAllowance("0x7099…79C8", 0, 1)'
Ожидание было обратным: готовая библиотека — значит «толще». На деле она меньше и дешевле. Средний контракт объясняет почему: стоит заменить строковые сообщения на свои ошибки, и рукописный токен худеет на 243 байта из 306. Тексты живут в байткоде; коды ошибок — нет.
Вторая часть вывода — про то, что библиотека проверяет за вас: перевод на нулевой адрес, нехватку разрешения, нехватку средств. Каждая ошибка приходит с числами: кто, сколько было, сколько запросили.
Имена ошибок вроде ERC20InsufficientBalance — не выдумка библиотеки, а отдельный стандарт на тексты ошибок токенов. Поэтому интерфейс, написанный под одну библиотеку, поймёт ошибки другой.
Что изменилось в версии 5
Разница между 4.x и 5.x — главная причина, по которой урок из интернета не собирается. Четыре изменения, которые встретятся почти сразу:
Владелец теперь передаётся в конструктор. Раньше владельцем становился тот, кто развернул контракт; теперь адрес передают явно:
contract FeeToken is ERC20, Ownable {
constructor(address initialOwner) ERC20("Fee", "FEE") Ownable(initialOwner) {}
}
Забыли аргумент — ошибка про то, что базовому конструктору не переданы значения.
Строковые сообщения заменены своими ошибками. Вместо текста — имя ошибки и числа.
Библиотека безопасной арифметики удалена. Файла просто нет: с версии языка 0.8 переполнение проверяет компилятор. Подключение даёт ошибку «файл не найден».
Два хука заменены одним. Раньше было «до перевода» и «после перевода»; теперь единственная точка — _update.
_update — единственная дверь
В версии 5 ни transfer, ни transferFrom, ни выпуск, ни сжигание не трогают балансы напрямую. Все четыре проходят через одну внутреннюю функцию:
function _update(address from, address to, uint256 value) internal override {
require(!paused, unicode"Токен на паузе");
// from == 0 — это выпуск, to == 0 — это сжигание: комиссию с них не берём
if (from != address(0) && to != address(0)) {
uint256 fee = value / 100; // 1 %
if (fee > 0) {
super._update(from, owner(), fee); // сама комиссия — тоже движение
value -= fee;
}
}
super._update(from, to, value);
}
Вторая модель сравнивает этот способ с «наивным» — где то же самое написано в переопределённом transfer:
── FeeToken: _update переопределён ──
комиссия владельцу с 1000 при переводе:
transfer : 10
transferFrom : 10
mint : 0 (выпуск: from == address(0))
burn : 0 (сжигание: to == address(0))
после setPaused(true):
transfer : reverted with reason string 'Токен на паузе'
transferFrom : reverted with reason string 'Токен на паузе'
mint : reverted with reason string 'Токен на паузе'
burn : reverted with reason string 'Токен на паузе'
── NaiveFeeToken: переопределён только transfer ──
комиссия владельцу с 1000 при переводе:
transfer : 10
transferFrom : 0
после setPaused(true):
transfer : reverted with reason string 'Токен на паузе'
transferFrom : прошло
mint : прошло
burn : прошло
── Ownable 5.x: чужой вызывает функцию владельца ──
reverted with custom error 'OwnableUnauthorizedAccount("0x7099…79C8")'
«Наивный» токен выглядит рабочим: комиссия списывается, пауза срабатывает. Но это проверяли одним способом перевода. Второй способ — списание по разрешению — проходит мимо: комиссии ноль, пауза не действует. Выпуск и сжигание тоже свободны.
Ровно так и обходят такие защиты: не ломают проверку, а идут другой дверью.
Внутри переопределения вызывают super._update(...), а не _update(...). Второе — вызов самого себя: бесконечная рекурсия, которая закончится тем, что транзакция сожжёт весь газ и откатится.
Расширения
Рядом с базовым токеном в библиотеке лежат готовые дополнения. Самые полезные для задания:
| Расширение | Что даёт |
|---|---|
| Сжигание | публичные функции сжечь свои токены или чужие по разрешению |
| Пауза | проверка паузы внутри _update, готовая ошибка |
| Потолок выпуска | нельзя выпустить больше заданного количества |
| Разрешение по подписи | выдать разрешение без отдельной транзакции |
Подключаются наследованием: contract T is ERC20, ERC20Burnable, ERC20Pausable. При этом компилятор потребует явно перечислить, чью версию _update вы берёте, — тот самый список после override, который мы разбирали в главе про наследование.
Там же, в библиотеке, лежит и стандарт долей хранилища — про него следующая глава.
Безопасный перевод чужого токена
Третья модель платит чужим токеном тремя способами — прямым вызовом, вызовом с проверкой признака и безопасным переводом из библиотеки:
── NoReturnToken — transfer без returns (bool) ──
без проверки : откат: function returned an unexpected amount of data
с проверкой : откат: function returned an unexpected amount of data
safeTransfer : ок, газ 39328, доехало 100
── LyingToken — transfer возвращает false ──
без проверки : ок, газ 27058, доехало 0
с проверкой : reverted with reason string 'Перевод не прошёл'
safeTransfer : reverted with custom error 'SafeERC20FailedOperation("0x9fE4…a6e0")'
── GoodToken — обычный ERC20 из библиотеки ──
без проверки : ок, газ 39648, доехало 100
с проверкой : ок, газ 39616, доехало 100
safeTransfer : ок, газ 39173, доехало 100
── почему прямой вызов откатывается на NoReturnToken ──
IERC20.transfer объявлен как returns (bool): вызов ждёт 32 байта ответа
NoReturnToken.transfer возвращает 0 байт — декодер откатывает всю транзакцию
SafeERC20 читает длину ответа: 0 байт при успешном вызове считаются успехом
Три вывода.
Токен без возврата признака ломает обычный вызов. Не потому, что перевод не прошёл, — а потому, что вызывающий ждал 32 байта ответа и не получил их. Такие токены существуют, и один из них — очень крупный.
Токен, который врёт, обманывает непроверенный вызов. «Ок, доехало 0» — это худший исход: транзакция успешна, учёт испорчен.
Безопасный перевод дешевле ручной проверки. На обычном токене — 39 173 против 39 616 газа. Он читает длину ответа и трактует её правильно: ноль байт при успешном вызове — это успех, 32 байта — надо смотреть на признак.
На площадке интернета нет
Проверка занимает одну команду в терминале рабочего места: посмотреть версию установленного пакета и содержимое его папки. Если пакет есть — работаем с библиотекой, экономим час. Если нет — пишем токен руками, как в прошлой главе.
Это проверяют в первые минуты модуля, а не когда контракт наполовину написан под библиотеку. Переписывание с библиотеки на рукописный токен за час до сдачи — потерянный модуль.
Типичные ошибки
are вместо is — та самая строка из контракта задания.
Владелец без адреса в конструкторе. В версии 5 аргумент обязателен.
Старый хук «до перевода». Его больше нет; точка расширения — _update.
Подключение удалённой библиотеки арифметики. Файл не найден: с версии языка 0.8 она не нужна.
decimals() без слова override. Компилятор потребует подтвердить переопределение.
Неполный список после override при нескольких расширениях: нужно перечислить всех родителей.
Вызов _update вместо super._update. Бесконечная рекурсия и сожжённый газ.
Защита, повешенная на transfer. Обходится списанием по разрешению.
Прямой вызов перевода на чужом токене. Может откатиться на разборе ответа или тихо вернуть неправду.
Слова главы
- Импорт из пакета — путь, начинающийся с имени пакета, а не с точки.
- Свои ошибки токена —
ERC20InsufficientBalance,ERC20InvalidReceiver,ERC20InsufficientAllowance. - Ошибка владельца —
OwnableUnauthorizedAccountвместо строкового сообщения. _update— единая точка любого движения баланса в версии 5.super._update— вызов базовой реализации; безsuperполучится рекурсия.- Расширения — сжигание, пауза, потолок выпуска, разрешение по подписи.
- Безопасный перевод — обёртка, которая корректно понимает токены без возврата признака.
- Совместимость 4.x → 5.x — конструктор владельца, свои ошибки, удалённые библиотеки, один хук вместо двух.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
На чемпионате
Модуль А, 37 баллов. Если библиотека на площадке есть, пять токенов задания пишутся за десять минут: пять наследников с разными именами и точностью. Если нет — работает шаблон из прошлой главы. Решение о том, каким путём идти, принимается в первые минуты, одной проверкой.
Модуль В, 14 баллов. Контракт для аудита наследует библиотечный токен, и это меняет то, что вы ищете: балансов и переводов в файле нет — значит, ошибки не в них, а в надстройке. Смотреть надо на выпуск без прав, на переопределения, повешенные не на ту функцию, на расчёты долей и на проверки.
Отдельный готовый пункт для отчёта по функционалу: перечислить, что именно контракт получает по наследству, — эксперт видит, что участник понимает, где заканчивается его код и начинается чужой.
Что дальше
Обычный токен считает балансы. Следующая глава — про токен, который считает доли: сколько вкладчик внёс относительно остальных и сколько заберёт, когда хранилище заработает. Это прямое требование задания к токену долей, и там же прячется уязвимость, из-за которой первый вкладчик может остаться ни с чем.
Углубиться
- Стандарт имён ошибок токенов — откуда взялись
ERC20InsufficientBalanceи почему их понимают разные библиотеки. - Расширение с правом голоса — токен, по которому считают голоса в управлении.
- Роли вместо одного владельца — когда администраторов несколько и права у них разные.
- Двухшаговая передача владения — защита от опечатки в адресе нового владельца.
- Токены с комиссией на перевод — почему протоколы обычно отказываются их поддерживать.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- ERC20 в OpenZeppelin — базовый контракт, расширения, полный список ошибок.
- Переход с 4.x на 5.0 — что удалено и что переименовано.
- Контроль доступа — владелец, роли, двухшаговая передача.
- SafeERC20 — безопасные переводы чужих токенов.
- EIP-6093: стандартные ошибки токенов — откуда имена ошибок в версии 5.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.