Перейти к основному содержимому
Блокчейн14ГЛАВА 14ERC20 с нуля: как устроен токен206 функций · 2 событиядоговор

ERC20 с нуля: как устроен токен

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Модуль А требует дословно: «Все токены системы, кроме shares token, соответствуют стандарту ERC20». По тексту задания таких токенов набирается пять: депозитные токены двух хранилищ и три залоговых, курсы которых меняются на шаге обновления логики.

Написать их придётся руками. Личный инструмент конкурсанта — «нулевой, нельзя ничего привозить», и отдельным пунктом стоит: «при выполнении модуля А у конкурсантов ограничен доступ к сети Интернет». Значит, на установку готовой библиотеки рассчитывать нельзя.

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

Стандарт — это договор, а не код​

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

СТАНДАРТ — ЭТО ДОГОВОР: ШЕСТЬ, ТРИ И ДВАшесть обязательных функцийtotalSupply()balanceOf(address)transfer(address, uint256)approve(address, uint256)allowance(address, address)transferFrom(address, address, uint256)три необязательных поляname() · symbol() · decimals()вызывающий не должен на них рассчитыватьдва событияTransfer(from, to, value)Approval(owner, spender, value)кошелёк и биржа понимают любой токен потому, что заранее знают эти подписивсё состояние токена — три переменные: итог выпуска, словарь балансов и словарь словарей разрешений
Шесть обязательных функций, три необязательных поля и два события — весь договор

Шесть обязательных функций:

ФункцияЧто делает
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
ЖУРНАЛ — САМОСТОЯТЕЛЬНЫЙ ИСТОЧНИК ПРАВДЫсобытия на местеTransfer от 0x000…000 → выпуск 1000Transfer на 0x000…000 → сжигание 20итог выпуска восстанавливается по журналу:1000 − 20 = 980 — совпало с контрактомзабыт один emit в transferбалансы честные: 300 и 200переводов в журнале: 0по журналу переведено 50,на самом деле 550на три обычных перевода приходится девять записей: пять о движении и четыре о разрешенияхперевод нуля тоже даёт событие — стандарт требует считать его обычным переводомкошелёк не покажет поступление, а дашборд, который считает метрики по событиям, покажет ноль
Итог выпуска восстанавливается по журналу: 1000 − 20 = 980, ровно то, что вернул контракт

Три наблюдения.

Разрешение перезаписалось. После второго approve стало 500, а не 700.

Списание расходует разрешение — и выпускает четвёртую запись Approval с остатком 80. Три вызова approve дали три записи, четвёртая появилась сама при списании.

Перевод нуля тоже даёт событие. Стандарт требует считать его обычным переводом — это не пустая формальность: некоторые системы используют нулевые переводы как отметку.

Отвечено вопросов: 0 из 2
anna дала vera разрешение на 200, потом вызвала approve(vera, 500). Каким стало разрешение?
Зачем при выпуске токенов выпускать событие Transfer с нулевого адреса?
Отвечено верно: 0 из 2

Две тихие поломки​

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

── 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 газа за перевод
ЦЕНА ЗАВИСИТ НЕ ОТ СУММЫ, А ОТ СОСТОЯНИЯ ЯЧЕЙКИперевод новому получателю52 232перевод тому, у кого баланс уже есть35 132первый approve46 777повторный approve29 677сброс approve в ноль24 793разница в обоих случаях ровно 17 100 — цена превращения нулевой ячейки в ненулевуюсам журнал тоже не бесплатен: два события стоят 1 953 газа за перевод и 518 байт кода
Цена зависит не от суммы, а от того, была ли ячейка получателя нулевой

Знакомые 17 100 газа появляются дважды: при первом переводе на адрес и при первом разрешении. Сумма перевода на цену не влияет вообще — влияет только состояние ячейки.

transferFrom дороже обычного перевода на 8 305 газа: это цена ещё одной записи — уменьшения разрешения.

И журнал не бесплатен: два события стоят 1 953 газа за перевод и 518 байт кода. Это не повод их убирать — это повод не выпускать событий сверх стандарта.

Отвечено вопросов: 0 из 2
Токен переводит правильно, но не выпускает событие Transfer. Что сломается?
Перевод 1 токена и перевод 1000 токенов одному и тому же адресу. Что дороже?
Отвечено верно: 0 из 2

Типичные ошибки​

Свой набор имён вместо стандартных. Кошелёк ищет ровно те подписи, что в договоре.

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 без прав (информационная безопасность), отсутствие событий (функционал и логика), непроверенный результат перевода у вызывающего, требование обнулять разрешение внутри контракта.

Отвечено вопросов: 0 из 2
Почему имя, символ и точность лучше передавать параметрами конструктора?
Что первым ищет аудитор в самописном токене?
Отвечено верно: 0 из 2

Что дальше​

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

Отвечено вопросов: 0 из 1
Сколько записей в журнале даёт цепочка «выпуск → перевод → approve → transferFrom → сжигание»?
Отвечено верно: 0 из 1

Углубиться​

  • Расширения стандарта — токен с возможностью сжигания владельцем, с ограничением выпуска, с паузой.
  • Разрешение по подписи — как выдать разрешение без отдельной транзакции.
  • Токены без возврата bool — почему такие существуют и как с ними работать безопасно.
  • Стандарт для невзаимозаменяемых токенов — чем отличается устройство, где у каждой единицы свой номер.
  • Предел размера контракта — что делать, когда байткод не влезает в 24 576 байт.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоСоздаем свой токен ERC 20 в Solidity с помощью библиотеки Openzeppelin. EthereumAlex Klimov
ВидеоЧто такое ERC20 токены - Стандарт ERC20CryptoInside
ВидеоSolidity и смарт-контракты Ethereum, урок #48 | ERC2612, ERC20Permit, аппрув безIlya Krukowski
ВидеоКРИПТО РАЗРАБОТКА на Solidity / Урок #1 – Введение в криптовалюты. Создание монеШкола itProger / Программирование
ВидеоERC20 | Solidity 0.8Smart Contract Programmer

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.