Токены и стандарты: ERC-20, ERC-721, ERC-1155 и что за ними стоит
Самое вредное словосочетание в теме — «токены в кошельке». Оно рисует сейф с монетками, и из этой картинки следуют неверные выводы: что кошелёк можно «взломать и вынуть», что токен «переехал из кошелька в кошелёк», что сеть «знает про мои активы». Ни одно из этих утверждений технически не верно.
Правда строже: токен — это строка в ассоциативном массиве внутри одного конкретного смарт-контракта. Всё остальное — договорённости, как эту строку менять и как об изменении рассказывать. ERC-20 — не «формат монеты», а шесть функций и два события, которые контракт обязуется реализовать с определённой семантикой, чтобы биржа, кошелёк и индексатор, написанные незнакомыми людьми, работали с ним без предварительного сговора.
Мы разобрали устройство EVM, аккаунты и газ и как пишут и деплоят контракты. Здесь — про интерфейсы, ставшие поверх этой машины общей инфраструктурой: почему они выглядят именно так, где острые углы и как об них режутся.
1. Что такое токен технически
Минимальный работающий токен помещается в десять строк:
contract TinyToken {
// Весь «баланс всех держателей» — это одно поле.
mapping(address => uint256) public balanceOf;
constructor(uint256 supply) { balanceOf[msg.sender] = supply; }
function transfer(address to, uint256 amount) external {
balanceOf[msg.sender] -= amount; // в 0.8.x недостача даст revert по underflow
balanceOf[to] += amount;
}
}
Ничего, кроме перекладывания числа между двумя ключами одного mapping. Ни ETH, ни какая-либо сущность уровня
протокола не двигается: с точки зрения Ethereum произошёл вызов контракта, изменивший два слота его storage.
Отсюда всё, что кажется контринтуитивным:
- Кошелёк не хранит токены — только ключ и список адресов контрактов, у которых имеет смысл спросить
balanceOf(мой адрес). «Добавить токен в MetaMask» значит вписать адрес в локальный список: строка в mapping существовала независимо от того, отображал её кто-то или нет. - Единственный идентификатор токена — адрес контракта.
nameиsymbolвыставит кто угодно. Контракт сsymbol = "USDC"по чужому адресу — стандартный скам: вам «дарят» миллион фейковых USDC, вы идёте продавать и подписываете то, что подсовывает сайт из описания токена. - Сеть не ведёт реестра ваших активов. Запроса «покажи все токены адреса 0xA1…9F» не существует; кошельки
отвечают на него, читая события
Transferчерез индексаторы — см. архитектуру dApp. - Отправка «не туда» необратима.
transferна адрес контракта, который про этот токен не знает, изменит запись, а распорядиться ею сможет только код этого контракта. Обычно его нет: токены не «теряются», они остаются на балансе адреса, у которого нет способа их сдвинуть.
2. Зачем нужен стандарт
Композируемость — единственная реальная суперсила публичного блокчейна: чужой контракт можно вызвать, не спрашивая
разрешения. Но только если заранее известны селекторы функций и смысл аргументов. Стандарт и есть этот минимум.
Формально это процесс EIP (EIP-1): статусы Draft → Review → Last Call → Final,
категория ERC — прикладной уровень, не требующий изменения протокола. Ключевое отличие от изменений
консенсуса: никто не обязан следовать ERC, ни одна нода не проверяет, что контракт «настоящий ERC-20».
| Что задаёт стандарт | Пример из ERC-20 | Кто это проверяет |
|---|---|---|
Селекторы (первые 4 байта keccak256 от сигнатуры) |
transfer(address,uint256) → 0xa9059cbb |
EVM при диспетчеризации вызова |
| Семантику: что происходит и когда откатиться | «transfer уменьшает баланс отправителя или revert» |
Никто. Только тесты и аудит |
| События для внешнего мира | Transfer(address,address,uint256) |
Индексаторы, которые на них живут |
Вторая строка — источник большинства проблем: компилятор проверит, что функция с нужным именем и типами есть, но не проверит, что она делает написанное в спецификации.
3. ERC-20: взаимозаменяемые токены
ERC-20 появился в 2015 году как черновик Фабиана Фогельстеллера и стал самым тиражируемым интерфейсом индустрии.
interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
// name(), symbol(), decimals() стандарт помечает как OPTIONAL — и это важно.
decimals — чисто отображательное поле. Дробной арифметики в контракте нет, балансы целые; decimals = 18
означает лишь договорённость делить на 10^18 при показе. У USDC это 6, у WBTC — 8. Захардкодить 18 в бэкенде —
классическая ошибка ценой в миллион раз. Эмиссии и сжигания в стандарте нет — как появляются и исчезают
токены, решает автор. Соглашение (не требование): минт логируется как Transfer из нулевого адреса, сжигание —
как Transfer в нулевой; индексаторы на это рассчитывают.
3.1. Модель allowance: почему перевод в два шага
Контракт не может «прийти и взять» ваши токены: у него нет вашего ключа, а transfer списывает только
с msg.sender. Значит, разрешение выдаётся заранее — это и есть двухшаговый танец approve / transferFrom.
Разделение на «разрешить» и «забрать» решает реальную задачу: контракт-получатель узнаёт о поступлении.
При простом transfer токен просто оказывается на балансе контракта, а тот об этом не уведомлён — хуков
у ERC-20 нет. Поэтому все протоколы работают по схеме «пользователь разрешил → контракт сам забрал внутри
своей функции и там же обновил учёт».
3.2. Референсная реализация
contract SimpleERC20 {
uint8 public immutable decimals; // immutable дешевле storage: значение вшито в байткод
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
error InsufficientBalance(uint256 available, uint256 required);
error InsufficientAllowance(uint256 available, uint256 required);
// immutable обязано быть присвоено в конструкторе — иначе код не скомпилируется.
constructor(uint8 _decimals) { decimals = _decimals; }
function transferFrom(address from, address to, uint256 value) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
// Бесконечный allowance принято не уменьшать: экономит запись в storage на каждой операции.
if (allowed != type(uint256).max) {
if (allowed < value) revert InsufficientAllowance(allowed, value);
unchecked { allowance[from][msg.sender] = allowed - value; }
}
_transfer(from, to, value);
return true;
}
function _transfer(address from, address to, uint256 value) internal {
uint256 bal = balanceOf[from];
if (bal < value) revert InsufficientBalance(bal, value);
unchecked {
balanceOf[from] = bal - value; // проверка выше делает вычитание безопасным
balanceOf[to] += value; // переполнение невозможно: сумма <= totalSupply
}
emit Transfer(from, to, value);
}
}
unchecked — не отключение безопасности, а снятие дублирующей проверки. С Solidity 0.8 арифметика
проверяется по умолчанию, каждая проверка стоит десятки газа; писать unchecked можно только там, где
невозможность выхода за границы доказана — здесь это проверка bal < value строкой выше и инвариант
«сумма всех балансов равна totalSupply».
Стоимость перевода определяется storage, а не логикой. Разложение типичного transfer: 21 000 базовой
стоимости транзакции, ~2 000 за calldata, по 2 100 за холодное чтение двух слотов баланса, 20 000 за запись
в ранее нулевой слот получателя (или 2 900, если он уже ненулевой), ~2 900 за обновление слота отправителя,
~1 700 за лог. Итого около 50 000 газа при первом получении токена адресом и около 35 000 при повторном:
полуторакратная разница целиком из правила «первая запись в пустой слот стоит 20 000»
(детали — в статье про EVM).
Событие — публичный API, а не логирование. Двигая балансы без emit Transfer, вы получите компилирующийся
токен, чьи перемещения невидимы внешнему миру: биржа не увидит поступления, обозреватель нарисует неверный
список держателей.
3.3. Где ERC-20 протекает
Спецификация оставила слишком много свободы, и реальные токены ею воспользовались. Каталог отклонений — d-xo/weird-erc20, читать обязательно перед любой интеграцией.
| Отклонение | Кто так делает | Что ломается | Как жить |
|---|---|---|---|
transfer не возвращает bool |
USDT, старый BNB | Вызов ревертит на декодировании ответа | SafeERC20: проверяет размер возврата |
Возврат false вместо revert |
часть старых токенов | Молча считаете перевод успешным | Всегда проверять возвращаемое значение |
| Комиссия при переводе | STA, PAXG, многое на BSC | Пришло меньше отправленного, учёт разъезжается | Считать balance до и после, брать дельту |
| Ребейз баланса | AMPL, stETH | Записанная сумма назавтра неверна | Хранить доли, не кэшировать балансы |
| Блок-лист адресов | USDC, USDT | transfer внезапно ревертит |
Путь отказа, не блокирующий весь протокол |
| Апгрейд через прокси | USDC, USDT | Логика может измениться завтра | Это доверие эмитенту, а не «код это закон» |
decimals не 18 |
USDC (6), WBTC (8) | Ошибка в миллион раз | Читать decimals(), не хардкодить |
Практический вывод: любой внешний токен — недоверенный контракт, и единственный корректный шаблон приёма выглядит так:
using SafeERC20 for IERC20; // OpenZeppelin: корректно обрабатывает токены без возвращаемого значения
function deposit(IERC20 token, uint256 amount) external {
uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount); // SafeERC20
uint256 received = token.balanceOf(address(this)) - before; // комиссия перевода учтена
_credit(msg.sender, received); // в учёт идёт фактически пришедшее, а не заявленное
}
3.4. Гонка на approve и бесконечные разрешения
У approve есть дефект, описанный ещё в 2016 году Михаилом Владимировым и Дмитрием Ховратовичем
(ERC-20 API: An Attack Vector on Approve/TransferFrom):
у спендера разрешение на 100, вы снижаете до 50 и шлёте approve(spender, 50). Спендер видит транзакцию
в мемпуле, вклинивается вперёд с большей ценой газа, тратит старые 100 — а затем ваша транзакция даёт ему
новые 50. Итого 150. Исторический обходной путь increaseAllowance/decreaseAllowance в OpenZeppelin
Contracts 5.0 удалён как создающий ложное чувство защищённости. Практическая защита: сначала
approve(spender, 0), затем approve(spender, N), а лучше не жить с висящими разрешениями.
Бесконечный approve (type(uint256).max) стал привычкой: экономит газ и подтверждение при каждом свопе.
Цена: если контракт с таким разрешением окажется апгрейдопригодным и его владельца скомпрометируют, все ваши
токены этого типа уходят одной транзакцией. Это не гипотетика — большая часть массовых дренажей 2022–2024 годов
шла через накопленные разрешения, а не через «взлом кошелька». Ревизия и отзыв разрешений (например,
через revoke.cash) — базовая гигиена, подробнее в статье
про кошельки и ключи.
3.5. EIP-2612 permit: подпись вместо транзакции
EIP-2612 убирает первую транзакцию: пользователь подписывает офчейн структуру EIP-712, а контракт-получатель предъявляет подпись токену.
bytes32 private constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function permit(address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
require(block.timestamp <= deadline, "permit expired");
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
);
// \x19\x01 — префикс EIP-712; DOMAIN_SEPARATOR привязывает подпись к chainId и адресу контракта
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash));
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0) && recovered == owner, "bad signature");
allowance[owner][spender] = value;
}
Каждый защитный элемент закрывает конкретную атаку. Nonce делает подпись одноразовой — иначе её предъявят
повторно. Deadline ограничивает окно, в котором застрявшая в мемпуле подпись остаётся годной.
DOMAIN_SEPARATOR включает chainId и адрес контракта: без него подпись из тестовой сети или для другого
токена была бы валидна и здесь — классическая replay-атака между цепями (механика подписей разобрана
в статье про криптографию Web3).
Риск, о котором надо говорить прямо. permit подписывается офчейн, бесплатно и без появления транзакции
в интерфейсе — для фишинга это удобнее approve: жертве показывают «подпишите вход на сайт», а подписывается
разрешение на весь баланс. Хороший кошелёк расшифровывает EIP-712 в читаемый вид: читайте поля spender
и value так же внимательно, как сумму в транзакции.
3.6. ERC-777 и цена обратного вызова
Отсутствие хуков многим казалось дефектом, и ERC-777 его «исправил»:
получатель и отправитель регистрируют колбэки tokensReceived / tokensToSend, которые токен обязан вызвать
при переводе. Последствие оказалось хуже болезни: любой перевод передаёт управление произвольному коду
в середине операции, то есть даёт точку реентрантности прямо внутри transfer.
- 18 апреля 2020, Uniswap V1 и токен imBTC — около 300 000 USD: повторный вход в
ethToTokenSwapInputчерезtokensToSendдо обновления резервов. - 19 апреля 2020, Lendf.Me — около 25 000 000 USD по тому же вектору во время
supply. - 30 августа 2021, Cream Finance и токен AMP — около 18 800 000 USD, снова колбэк ERC-777 в процессе займа.
Мораль не «ERC-777 плохой», а общая: добавляя в стандарт обратный вызов, вы делаете реентрантность свойством
любого перевода. Для интегратора вывод такой: даже работая с интерфейсом IERC20, считайте transfer
внешним вызовом недоверенного кода. Checks-Effects-Interactions и nonReentrant — не паранойя, а следствие
того, что вы не знаете, какой токен вам передадут (разбор класса — в статье
про безопасность контрактов).
4. ERC-165: как спросить контракт, что он умеет
В EVM нет рефлексии: узнать, какие функции есть у контракта, нельзя.
ERC-165 вводит соглашение — одну функцию про поддержку интерфейса,
где interfaceId есть XOR селекторов всех функций интерфейса.
function supportsInterface(bytes4 id) public pure returns (bool) {
return id == 0x01ffc9a7 // IERC165
|| id == 0x80ac58cd // IERC721
|| id == 0x5b5e139f; // IERC721Metadata (у IERC1155 это 0xd9b67a26)
}
Ограничение стоит понимать честно: supportsInterface — заявление контракта о себе, а не доказательство;
вредоносный контракт вернёт true на что угодно. Механизм годится для отсеивания случайных несовместимостей
(маркетплейс различает ERC-721 и ERC-1155), но не является проверкой безопасности. ERC-20 старше ERC-165
и его не поддерживает: «это ERC-20?» определяется только эвристикой — успешным staticcall на totalSupply().
Обратите внимание на последнюю связь: ERC-4626 сам является ERC-20. Доля в хранилище — обычный взаимозаменяемый токен, который можно переводить и класть в другие протоколы. Так работает композируемость: стандарт наследуется, а не изобретается заново.
5. ERC-721: невзаимозаменяемые токены
ERC-721 меняет единицу учёта: вместо «сколько у адреса» хранится
«кому принадлежит вот этот tokenId». Вся разница — в направлении отображения:
mapping(address => uint256) balanceOf; // ERC-20: адрес -> количество
mapping(uint256 => address) private _owners; // ERC-721: идентификатор -> владелец
interface IERC721 is IERC165 {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
function ownerOf(uint256 tokenId) external view returns (address);
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external; // право на один токен
function setApprovalForAll(address operator, bool approved) external; // право на всю коллекцию
}
Из инверсии следует всё остальное: ownerOf(tokenId) дёшев, а «покажи все токены адреса» дорог — нужен либо
перебор, либо отдельный индекс (ERC721Enumerable, стоящий газа на каждом переводе), либо офчейн-индексатор;
в проде почти всегда выбирают последнее. Заметьте также, что в ERC-721 все три поля Transfer индексированы,
включая tokenId: историю конкретного токена достаёт один запрос к логам. В ERC-20 так нельзя — там третье
поле (сумма) не индексировано.
5.1. safeTransferFrom и хук приёмника
transferFrom перекладывает владение молча: если получатель — контракт, не умеющий работать с NFT, токен
становится недоступен навсегда. safeTransferFrom добавляет проверку: контракту-получателю вызывают
onERC721Received, и перевод проходит, только если тот вернул ровно свой селектор
(bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))).
Два нюанса. Первый: проверка «получатель — контракт?» делается через to.code.length == 0, и это
не эквивалент «это точно EOA» — внутри конструктора у контракта кода ещё нет. Второй: хук передаёт
управление получателю в середине операции, то есть safeTransferFrom — точка реентрантности. Поэтому
OpenZeppelin обновляет владельца до вызова хука, а собственную денежную логику надо писать
по Checks-Effects-Interactions.
5.2. Метаданные: чем именно вы «владеете»
tokenURI(tokenId) возвращает ссылку на JSON, формат которого закреплён де-факто
документацией OpenSea: name, description, image
и массив attributes. Скажем прямо то, что часто замалчивают: в блокчейне лежит только строка tokenURI
и запись о владельце; картинки в блокчейне нет. Отсюда три разных инженерных варианта:
| Куда указывает tokenURI | Если источник исчез | Кто контролирует содержимое |
|---|---|---|
https://api.проект.io/token/42 |
Метаданные и картинка пропадают, остаётся число | Владелец домена: подменит в любой момент |
ipfs://<CID>/42.json |
Пока хоть один узел хранит блок — доступно | Никто: CID есть хеш содержимого |
data:application/json;base64,... |
Ничего не исчезнет, всё в состоянии сети | Никто, но каждый байт оплачен газом |
Вторая строка — контентная адресация — и есть причина, по которой децентрализованные хранилища не «дополнение
к NFT», а несущая часть конструкции: подменить картинку, не сломав ссылку, физически невозможно. Третий вариант
(ончейн SVG, генерируемый прямо в tokenURI) применяют там, где важна вечность без внешних зависимостей:
так Uniswap V3 рисует NFT своих позиций.
5.3. Газ, батч-минт и риски
Наивный минт 10 000 токенов циклом — это 10 000 записей по 20 000 газа, порядка 200 000 000 газа: больше
нескольких блоков. Отсюда два приёма. ERC721A (chiru-labs/ERC721A)
хранит владельца только для первого токена в пачке, а ownerOf ищет ближайшую заполненную запись назад:
минт десяти становится почти таким же дешёвым, как минт одного. Честная цена — ownerOf и transferFrom
дорожают, то есть выигрыш достаётся минтеру, а платит будущий продавец. Ленивый минт переносит выпуск
на момент первой продажи: автор подписывает офчейн-ваучер, токен создаётся в транзакции покупателя.
Риски, которые надо называть вслух. setApprovalForAll — основной вектор дренажа: одна подпись даёт
оператору право двигать любой ваш токен коллекции бессрочно, и фишинговый сайт под видом маркетплейса просит
ровно это. Подписанные ордера маркетплейсов: в Seaport и аналогах продажа — офчейн-подпись, исполняемая
позже, поэтому, подписав «ордер на продажу за 0», вы отдаёте предмет бесплатно, и транзакции в кошельке
при этом не будет. Владение NFT не равно правам на изображение: контракт фиксирует запись в реестре,
а авторские права передаются лицензией вне сети или не передаются вовсе.
6. ERC-1155: мультитокен
ERC-1155 родился из игровой практики Enjin: тысячи типов предметов,
часть взаимозаменяемая (руда, зелья), часть уникальная (именной меч), и деплоить контракт на каждый тип абсурдно
дорого. Решение — двумерный учёт mapping(uint256 id => mapping(address => uint256)): один контракт, много
идентификаторов, у каждого свой баланс у каждого адреса.
interface IERC1155 is IERC165 {
event TransferBatch(address indexed operator, address indexed from, address indexed to,
uint256[] ids, uint256[] values);
function balanceOf(address account, uint256 id) external view returns (uint256);
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory);
function safeBatchTransferFrom(address from, address to, uint256[] calldata ids,
uint256[] calldata values, bytes calldata data) external;
function setApprovalForAll(address operator, bool approved) external;
}
Взаимозаменяемость здесь — не свойство стандарта, а свойство поведения контракта: если у id = 7 общий
выпуск равен единице и больше не минтится, это NFT; если пятьсот — тиражный предмет. Смысл битов идентификатора
стандарт не навязывает, и распространённое соглашение — расщепить uint256 пополам:
Где именно экономия батча. 21 000 газа базовой стоимости транзакции платятся один раз, а не N (на десяти
переводах — 189 000 газа чистой экономии); isApprovedForAll читается однократно; вместо N логов пишется один
TransferBatch. Реалистичная оценка: перевод десяти типов предметов батчем примерно втрое дешевле десяти
отдельных транзакций ERC-721. Обратная сторона — два массива, обязанных совпадать по длине, и цикл по ним,
ограниченный газом блока: массив, длину которого задаёт пользователь, это всегда потенциальный DoS по газу.
Хук получателя здесь обязателен — в отличие от ERC-721, где остаётся небезопасный transferFrom в обход
проверки. Любой перевод контракту вызывает onERC1155Received (magic value 0xf23a6e61) или
onERC1155BatchReceived и требует возврата ровно этого значения. Прямое следствие: каждый перевод ERC-1155
контракту передаёт управление принимающему коду — ровно то свойство, которое погубило Uniswap V1 на ERC-777.
uri(id) возвращает шаблон, где клиент сам подставляет идентификатор: ipfs://bafy…/{id}.json.
По стандарту {id} заменяется на шестнадцатеричное представление, дополненное нулями до 64 символов, без
префикса 0x: для id = 42 это 000…02a.json. Так не нужно хранить строку URI для каждого идентификатора —
экономия огромная, но клиенты, забывшие про формат, получают 404.
7. Три стандарта рядом
| Критерий | ERC-20 | ERC-721 | ERC-1155 |
|---|---|---|---|
| Единица учёта | адрес → количество | tokenId → владелец | (id, адрес) → количество |
| Взаимозаменяемость | да | нет | и то, и другое |
| Хуки получателя | нет | опционально | обязательно |
| Батч-операции | нет | нет | встроенные |
| ERC-165 | не поддерживает | поддерживает | поддерживает |
| Гранулярное разрешение | approve(spender, N) |
approve(to, tokenId) |
только на всё сразу |
| Метаданные | name/symbol/decimals |
tokenURI(id) |
uri(id) с шаблоном {id} |
| Цена перевода | ~35–50 тыс. газа | ~50–70 тыс. газа | от ~50 тыс., батч дешевле на единицу |
| Применение | деньги, доли, права голоса | уникальные предметы, членство | игровые инвентари, тиражные предметы |
Слабое место ERC-1155: нет разрешения на конкретный id. Дать маркетплейсу право продать один предмет нельзя — только доступ ко всему инвентарю. Для игры с одним доверенным контрактом это приемлемо, для торговых сценариев — заметный минус в модели безопасности.
между собой?"} B -->|"Нет, взаимозаменяемы"| C{"Нужны подписи
вместо approve?"} C -->|"Да"| D["ERC-20 плюс EIP-2612 permit"] C -->|"Нет"| E["ERC-20"] B -->|"Да, каждая уникальна"| F{"Сколько типов
в приложении?"} F -->|"Один-два, переводы редки"| G["ERC-721"] F -->|"Десятки, переводы массовые"| H["ERC-1155"] B -->|"Смешанно"| H D --> I{"Токен — доля в пуле активов?"} E --> I I -->|"Да"| J["ERC-4626 поверх ERC-20"] I -->|"Нет"| K["Готово"] G --> L{"Нужна непередаваемость?"} L -->|"Да: диплом, репутация"| M["ERC-5192 soulbound"] L -->|"Нет"| K
8. Что выросло поверх
Коротко о менее известных. ERC-5192 — NFT с флагом locked(tokenId), перевод запрещён: дипломы, репутация,
доступ. ERC-4907 добавляет к ERC-721 роль «пользователя» с истечением срока: владелец остаётся владельцем,
но userOf(tokenId) до expires — другой адрес, то есть аренда без эскроу.
ERC-6551 даёт каждому ERC-721 детерминированный (через CREATE2)
адрес смарт-аккаунта: персонаж-NFT сам владеет предметами, и при продаже инвентарь переходит вместе с ним.
8.1. ERC-4626 и inflation-атака
ERC-4626 стандартизирует «хранилище»: вы кладёте актив (asset),
получаете доли (shares), доли сами являются ERC-20. Внутри — простая пропорция
shares = assets * totalSupply / totalAssets(), и ровно в ней спрятана известнейшая уязвимость стандарта —
inflation attack, она же атака первого вкладчика. По шагам, на пустом хранилище:
- Атакующий кладёт 1 wei:
totalSupply == 0, поэтому он получает 1 долю. - Атакующий переводит напрямую на адрес хранилища 10 000 токенов обычным
transfer, минуяdeposit. Долей не выпущено, ноtotalAssets(), если он считается какasset.balanceOf(address(this)), стал10000e18 + 1: одна доля теперь стоит всё содержимое. - Честный пользователь кладёт 10 000 токенов:
shares = 10000e18 * 1 / (10000e18 + 1) = 0— целочисленное деление округляет вниз, и он получает ноль долей за реальный депозит. - Атакующий гасит свою единственную долю и забирает всё.
Проблема не в «плохом коде», а в сочетании трёх вещей: округления вниз, возможности изменить totalAssets
переводом мимо учёта и малого totalSupply на старте. Промышленные защиты:
- Виртуальные доли и активы (decimals offset) — OpenZeppelin добавляет к обеим величинам смещение, из-за чего атака перестаёт окупаться: чтобы съесть депозит жертвы, надо подарить хранилищу больше, чем украдёшь (разбор с математикой).
- Мёртвые доли при деплое — контракт сам минтит, например, 1000 долей на нулевой адрес, и
totalSupplyникогда не бывает опасно малым (приём взят из Uniswap V2). - Внутренний учёт вместо
balanceOf— тогда прямой перевод на адрес контракта на курс не влияет вовсе. - Округление всегда в пользу хранилища:
previewDepositвниз,previewWithdrawвверх; стандарт это прямо предписывает, и нарушение — отдельный класс багов.
Общий урок за пределами 4626: любая формула вида a * b / c в денежном коде требует явного ответа
на вопрос, куда округляется остаток и кому достаётся выигрыш от округления.
9. Интеграция чужого токена и честный список рисков
Принимая произвольный ERC-20 или ERC-721, вы принимаете произвольный чужой код. Минимум, проверенный практикой:
SafeERC20 на все вызовы transfer/transferFrom/approve (из-за токенов без возвращаемого значения);
учёт фактически пришедшего через разницу балансов, а не заявленного в аргументе; никакого хардкода decimals;
внешний вызов токена последним действием функции при уже обновлённом состоянии; pull вместо push; никаких
циклов по спискам, длину которых контролирует пользователь; balanceOf пула не используется как источник
цены (это спот-курс, двигаемый флеш-займом в пределах одной транзакции); форк-тесты на реальном состоянии
сети с закреплённым номером блока — только так вы поймаете поведение конкретного USDT, а не своего мока
(инструменты — в статье про смарт-контракты).
Механики мошенничества, проверяемые чтением кода:
- Honeypot-токены — контракт разрешает покупку и ревертит на продаже для всех, кроме белого списка;
ищется как условие «если
toесть пул иfromне в списке — revert» внутри_transfer. - Неограниченная эмиссия —
mintбез ограничений и таймлока: не всегда злой умысел, но всегда доверие конкретным людям. Смотрите заодно, кто владелец контракта: EOA у крупного токена — красный флаг, а не деталь. - Апгрейд за прокси — токен завтра может работать иначе, чем сегодня; проверяйте слот EIP-1967 и того, кто вправе менять реализацию.
- Airdrop-приманки — токены, «упавшие» на ваш адрес, часто существуют ради того, чтобы вы пошли продавать
их на подставной сайт и подписали
approveилиpermit; достаточно скрыть их в интерфейсе. - Необратимость — ни одна из ситуаций не отменяется откатом: ни поддержки, ни чарджбэка, это следствие модели из статьи про консенсус.
Это не советы, что делать с активами, — это инженерные свойства систем, которые нужно уметь проверять в коде до того, как подключать их к своему.
Итог
Токен — не сущность протокола, а прикладной паттерн: mapping внутри контракта плюс набор обещаний о том, как его менять и как об этом рассказывать. Стандарты ERC — минимальные контракты совместимости, делающие композируемость возможной, и вся их сила и слабость растут из одного корня: их соблюдение никто не проверяет, кроме вас.
- ERC-20 — простой интерфейс с двухшаговой моделью
approve/transferFrom; основные проблемы лежат не в спецификации, а в её вольных трактовках реальными токенами. - ERC-165 — единственный способ спросить контракт об интерфейсе, и это заявление, а не доказательство.
- ERC-721 инвертирует направление учёта (
id → владелец): дешёвыйownerOf, дорогой обход коллекции, метаданные вне сети — и от адреса метаданных зависит, что вы на самом деле получили. - ERC-1155 объединяет оба режима и даёт батчи ценой обязательных хуков, то есть реентрантности в каждом переводе.
- ERC-4626 показывает общий закон денежного кода: округление, деление и внешне изменяемый знаменатель вместе дают уязвимость, даже когда каждая часть выглядит безобидно.
- Любой чужой токен — недоверенный контракт.
SafeERC20, дельта балансов, отсутствие циклов по пользовательским массивам, форк-тесты — не перестраховка, а стоимость входа.
Источники
- EIP-20, EIP-721, EIP-1155, EIP-165, EIP-712, EIP-2612, EIP-4626 — первоисточники со всей нормативной лексикой.
- OpenZeppelin Contracts — референсные реализации и разделы Security Considerations; d-xo/weird-erc20 — каталог реальных отклонений от ERC-20.
- ERC721A и Solmate — газоэффективные реализации с иным набором компромиссов.
- OpenSea Metadata Standards — де-факто формат метаданных.
- Rekt и DefiLlama Hacks — хроника инцидентов, включая разобранные выше атаки через ERC-777.
Что дальше
Мы дважды упёрлись в один вопрос: tokenURI возвращает строку — а где лежит то, на что она указывает?
Если по HTTP, вся неизменяемость реестра обесценивается одним выключенным сервером. Ответ — контентная
адресация и сети, которые её реализуют.
Децентрализованные хранилища: IPFS, Filecoin, Arweave, контентная адресация