ERC20 с нуля: как устроен токен
- Зачем это нужно
- Стандарт — это договор, а не код
- Всё состояние — три переменные
- transfer снаружи, _transfer внутри
- approve перезаписывает, а не прибавляет
- Выпуск с нулевого адреса, сжигание на нулевой
- Весь сценарий: девять записей в журнале
- Две тихие поломки
- decimals — только про отображение
- Сколько стоит стандарт
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Модуль А требует дословно: «Все токены системы, кроме shares token, соответствуют стандарту ERC20». По тексту задания таких токенов набирается пять: депозитные токены двух хранилищ и три залоговых, курсы которых меняются на шаге обновления логики.
Написать их придётся руками. Личный инструмент конкурсанта — «нулевой, нельзя ничего привозить», и отдельным пунктом стоит: «при выполнении модуля А у конкурсантов ограничен доступ к сети Интернет». Значит, на установку готовой библиотеки рассчитывать нельзя.
Эта глава — весь стандарт целиком: шесть обязательных функций, три необязательных поля и два события, которые надо уметь набрать по памяти за десять минут.
Стандарт — это договор, а не код
Стандарт не даёт готового контракта. Он перечисляет имена, параметры и типы возврата — чтобы любой кошелёк и любая биржа заранее знали, как разговаривать с токеном.
Шесть обязательных функций:
| Функция | Что делает |
|---|---|
totalSupply() | сколько токенов выпущено всего |
balanceOf(address) | баланс одного адреса |
transfer(address, uint256) | перевести со своего счёта |
approve(address, uint256) | разрешить кому-то списывать с моего счёта |
allowance(address, address) | сколько разрешено списать |
transferFrom(address, address, uint256) | списать по разрешению |
Три необязательных поля — name(), symbol(), decimals(). Стандарт про каждое пишет одно и то же: поле необязательное, и вызывающий не должен рассчитывать, что оно есть.
Два события — Transfer и Approval.
Всё состояние — три переменные
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allow;
Вложенный словарь читается как «сколько владелец разрешил списать конкретному получателю»: _allow[anna][vera]. Именно поэтому allowance принимает два адреса.
Автоматическая читалка, которую компилятор создаёт для public-словаря с одним ключом, принимает один адрес — и подпись стандарта не закрывает. Контракт, который объявил себя выполняющим договор, но обошёлся простым словарём, не соберётся: компилятор потребует пометить его абстрактным и покажет, какой именно функции не хватает.
transfer снаружи, _transfer внутри
function transfer(address to, uint256 value) public returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function _transfer(address from, address to, uint256 value) internal {
require(to != address(0), unicode"Перевод на нулевой адрес");
require(_balances[from] >= value, unicode"Недостаточно средств");
_balances[from] -= value;
_balances[to] += value;
emit Transfer(from, to, value);
}
Разбиение на публичную функцию и внутреннюю — не украшение: одна и та же проверка нужна трём способам сдвинуть баланс.
approve перезаписывает, а не прибавляет
Разрешение — это лимит, и повторный вызов его заменяет. Стандарт говорит об этом прямо: если функция вызвана снова, она перезаписывает текущее разрешение.
Отсюда известная рекомендация текста стандарта: клиентам следует сначала выставить разрешение в ноль, а потом новое значение — иначе получатель успевает израсходовать старое и новое одной цепочкой. Тут же оговорка, которую в отчётах обычно теряют: сам контракт не должен требовать этого обнуления — это забота интерфейса.
Выпуск с нулевого адреса, сжигание на нулевой
function _mint(address to, uint256 value) internal {
_totalSupply += value;
_balances[to] += value;
emit Transfer(address(0), to, value); // правило стандарта
}
function _burn(address from, uint256 value) internal {
require(_balances[from] >= value, unicode"Недостаточно средств");
_balances[from] -= value;
_totalSupply -= value;
emit Transfer(from, address(0), value);
}
Стандарт требует: контракт, который создаёт новые токены, обязан выпустить событие Transfer с нулевым адресом отправителя. Сжигание — зеркало.
Благодаря этим двум правилам внешний наблюдатель восстанавливает итог выпуска по одним событиям, не спрашивая контракт.
Обе функции помечены internal — видны себе и наследникам, но не снаружи. Наружу выставляют обёртку с проверкой прав:
function mint(address to, uint256 value) public {
require(msg.sender == owner, unicode"Только владелец");
_mint(to, value);
}
Без такой обёртки печатать деньги сможет кто угодно — и это первый пункт, который ищет аудитор.
Весь сценарий: девять записей в журнале
Первая модель проходит полный путь токена — выпуск, перевод, разрешение, списание по разрешению, перевод нуля, сжигание:
── паспорт токена: три необязательных поля ──
name: Mini Token symbol: MINI decimals: 18
totalSupply сразу после развёртывания: 0
── mint 1000 для anna ──
totalSupply сырыми единицами: 1000000000000000000000
totalSupply с учётом decimals: 1000.0
── anna → boris: transfer 300 ──
anna: 700.0 boris: 300.0
totalSupply не изменился: 1000.0
── anna разрешает vera 200 ──
allowance(anna, vera): 200.0
баланс vera: 0.0 — разрешение денег не даёт
ещё раз approve на 500 — стало: 500.0 (перезапись, а не 700)
── vera списывает 120 с anna в пользу boris ──
anna: 580.0 boris: 420.0 vera: 0.0
остаток разрешения: 80.0
ещё 100 сверх остатка: reverted with reason string 'Недостаточно разрешения'
── перевод нуля: стандарт требует считать его обычным переводом ──
balanceOf(vera): 0.0 — событие всё равно выпущено
── boris сжигает 20 ──
boris: 400.0 totalSupply: 980.0
── весь журнал событий токена ──
Transfer from=0x0000…0000 to=0x7099…79C8 value=1000.0
Transfer from=0x7099…79C8 to=0x3C44…93BC value=300.0
Approval owner=0x7099…79C8 spender=0x90F7…b906 value=200.0
Approval owner=0x7099…79C8 spender=0x90F7…b906 value=500.0
Approval owner=0x7099…79C8 spender=0x90F7…b906 value=200.0
Approval owner=0x7099…79C8 spender=0x90F7…b906 value=80.0
Transfer from=0x7099…79C8 to=0x3C44…93BC value=120.0
Transfer from=0x3C44…93BC to=0x90F7…b906 value=0.0
Transfer from=0x3C44…93BC to=0x0000…0000 value=20.0
всего событий: 9
Три наблюдения.
Разрешение перезаписалось. После второго approve стало 500, а не 700.
Списание расходует разрешение — и выпускает четвёртую запись Approval с остатком 80. Три вызова approve дали три записи, четвёртая появилась сама при списании.
Перевод нуля тоже даёт событие. Стандарт требует считать его обычным переводом — это не пустая формальность: некоторые системы используют нулевые переводы как отметку.
Две тихие поломки
Вторая модель показывает два отступления от стандарта, которые компилятор пропускает молча.
── SilentToken: transfer без emit Transfer ──
balanceOf(anna): 300.0
balanceOf(boris): 200.0
событий Transfer в журнале: 1
from=0x000000 value=1000.0
из них переводов между людьми: 0 — кошелёк и индексатор их не увидят
сумма переводов по журналу: 50.0
сумма переводов на самом деле: 550.0
── FalseToken: на кассе 40 токенов, просим выплатить 60 ──
NaiveDesk (bool не проверяет):
транзакция прошла: да
записано в учёте кассы paidOut[anna]: 60.0
реально получено anna: 0.0
осталось на кассе: 40.0
SafeDesk (проверяет bool):
reverted with reason string 'Перевод токена не прошёл'
записано в учёте кассы: 0.0
── расхождение учёта у наивной кассы ──
totalPaid по её собственным записям: 60.0
токенов ушло с её адреса: 0.0
Забытый emit. Балансы честные, а журнал пуст. Кошелёк не покажет поступление, индексатор не построит ленту, а дашборд модуля А, который считает метрики по событиям, покажет ноль. Причём частично: transferFrom, где emit на месте, свои 50 записал — расхождение получилось «правдоподобным», а значит, найдётся ещё позже.
Возврат false вместо отката. Стандарт не оставляет выбора: вызывающий обязан обрабатывать false и не имеет права считать, что false никогда не вернётся. Наивная касса записала себе выплату 60, тогда как ушло ноль. Касса с проверкой откатилась и учёт не испортила.
decimals — только про отображение
В хранилище живут целые числа. После выпуска «1000 токенов» итог равен 1000000000000000000000 — это 1000 × 10¹⁸. Контракт про запятую не знает ничего; decimals — подсказка интерфейсу, куда её нарисовать.
Практическое следствие для задания: «минимальный депозит 10 USDC» в коде выглядит как 10 * 10**decimals(). Если у вашего токена точность 6, а вы сравниваете с 10 ether (это 10 × 10¹⁸), порог окажется в триллион раз выше задуманного — и шаг демонстрации с депозитом откатится.
Никогда не пишите количество токенов константой вида 10 ether или 1e18, если точность токена не 18. Считайте через decimals() самого контракта — ту же ошибку мы уже видели в главе про наследование, где «один токен» превратился в миллион.
Сколько стоит стандарт
Третья модель замеряет цену операций:
── перевод: новый получатель против уже имеющего баланс ──
anna, баланс был 0 (нулевая ячейка заполняется): 52232
anna, баланс уже ненулевой (перезапись): 35132
разница за заполнение нулевой ячейки: 17100
── approve: первый и повторный ──
первый approve (0 → 500): 46777
повторный approve (500 → 700): 29677
сброс approve (700 → 0): 24793
── transferFrom: перевод плюс списание разрешения ──
transferFrom существующему получателю: 43437
тот же перевод обычным transfer: 35132
── размер байткода и цена развёртывания ──
MiniERC20 (с событиями): байт 5532 газ развёртывания 1348708
MiniERC20NoEvents (без них): байт 5014 газ развёртывания 1236694
предел размера контракта в сети: 24576 байт
── цена самих emit внутри перевода ──
transfer с emit Transfer: 35132
transfer без emit: 33179
журнал стоит: 1953 газа за перевод
Знакомые 17 100 газа появляются дважды: при первом переводе на адрес и при первом разрешении. Сумма перевода на цену не влияет вообще — влияет только состояние ячейки.
transferFrom дороже обычного перевода на 8 305 газа: это цена ещё одной записи — уменьшения разрешения.
И журнал не бесплатен: два события стоят 1 953 газа за перевод и 518 байт кода. Это не повод их убирать — это повод не выпускать событий сверх стандарта.
Типичные ошибки
Свой набор имён вместо стандартных. Кошелёк ищет ровно те подписи, что в договоре.
allowance через простой словарь. Автоматическая читалка принимает один адрес, а договор требует два.
_mint без обёртки с проверкой прав. Печатать деньги сможет кто угодно.
Забытый emit Transfer в переводе или выпуске. Балансы верные, внешний мир слеп.
Возврат false вместо отката, а вызывающий не проверяет. Учёт вызывающего расходится с реальностью.
Количество токенов константой 1e18 при другой точности. Пороги и суммы уезжают на порядки.
Проверка to != address(0) забыта. Перевод на нулевой адрес сожжёт токены, но итог выпуска не уменьшится — учёт разъедется.
Обнуление разрешения, требуемое самим контрактом. Стандарт прямо говорит, что контракт этого требовать не должен.
События сверх стандарта в каждом действии. Каждое стоит газа и места в коде; предел размера контракта — 24 576 байт.
Слова главы
- Стандарт токена — договор об именах, параметрах и возвращаемых типах.
totalSupply/balanceOf— итог выпуска и баланс адреса.transfer/transferFrom— перевод со своего счёта и по разрешению.approve/allowance— выдать разрешение и узнать остаток; разрешение перезаписывается.Transfer/Approval— два обязательных события.- Выпуск (mint) — событие
Transferс нулевого адреса; сжигание (burn) — на нулевой. internal-функции с подчёркиванием — служебные; наружу выставляют обёртку с правами.decimals— только про отображение: в хранилище целые числа.- Предел размера контракта — 24 576 байт байткода в сети.
Тренажёр: набрать вслепую
Шесть подписей — то, что на площадке набирают по памяти.
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
function transfer(address to, uint256 value) public returns (bool) {
На чемпионате
Модуль А, 37 баллов. Токенов по заданию пять, и все — по этому стандарту. Практические выводы:
- шаблон токена набирается по памяти: три переменные, шесть функций, два события;
- имя, символ и точность приходят параметрами конструктора — так один файл обслуживает все пять токенов;
mintпод проверкой прав, иначе на демонстрации любой участник сможет напечатать себе баланс;- количества считаются через
decimals(), а не константами.
Шаги демонстрации вида «показать изменения всех затронутых балансов» опираются и на функции чтения, и на журнал: если событий нет, эксперту нечего показать, кроме чисел.
Модуль В, 14 баллов. Готовые пункты: mint без прав (информационная безопасность), отсутствие событий (функционал и логика), непроверенный результат перевода у вызывающего, требование обнулять разрешение внутри контракта.
Что дальше
Токен, написанный руками, работает — но в реальных проектах его почти всегда берут готовым. Следующая глава — про библиотеку, где этот стандарт уже реализован и проверен: что она делает за вас, чем её версия отличается от нашей, какие тексты ошибок возвращает и что делать, если на площадке её не окажется.
Углубиться
- Расширения стандарта — токен с возможностью сжигания владельцем, с ограничением выпуска, с паузой.
- Разрешение по подписи — как выдать разрешение без отдельной транзакции.
- Токены без возврата
bool— почему такие существуют и как с ними работать безопасно. - Стандарт для невзаимозаменяемых токенов — чем отличается устройство, где у каждой единицы свой номер.
- Предел размера контракта — что делать, когда байткод не влезает в 24 576 байт.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- EIP-20: стандарт токена — полный текст договора, включая требования к событиям и обработке
false. - Интерфейс IERC20 в OpenZeppelin — те же шесть функций и два события в готовом виде.
- События — docs.soliditylang.org —
emit,indexed, устройство журнала. - Цены операций: EIP-2929 — откуда берутся 17 100 газа при первой записи.
- Предел размера контракта: EIP-170 — почему 24 576 байт.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.