Web3 и блокчейн Токены и стандарты: ERC-20, ERC-721, ERC-1155 и что за ними стоит
0%

Токены и стандарты: ERC-20, ERC-721, ERC-1155 и что за ними стоит

Токены и стандарты: 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 пополам:

Раскладка uint256 id в ERC-1155

Где именно экономия батча. 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. Дать маркетплейсу право продать один предмет нельзя — только доступ ко всему инвентарю. Для игры с одним доверенным контрактом это приемлемо, для торговых сценариев — заметный минус в модели безопасности.

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. Атакующий кладёт 1 wei: totalSupply == 0, поэтому он получает 1 долю.
  2. Атакующий переводит напрямую на адрес хранилища 10 000 токенов обычным transfer, минуя deposit. Долей не выпущено, но totalAssets(), если он считается как asset.balanceOf(address(this)), стал 10000e18 + 1: одна доля теперь стоит всё содержимое.
  3. Честный пользователь кладёт 10 000 токенов: shares = 10000e18 * 1 / (10000e18 + 1) = 0 — целочисленное деление округляет вниз, и он получает ноль долей за реальный депозит.
  4. Атакующий гасит свою единственную долю и забирает всё.

Проблема не в «плохом коде», а в сочетании трёх вещей: округления вниз, возможности изменить 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, дельта балансов, отсутствие циклов по пользовательским массивам, форк-тесты — не перестраховка, а стоимость входа.

Источники

Что дальше

Мы дважды упёрлись в один вопрос: tokenURI возвращает строку — а где лежит то, на что она указывает? Если по HTTP, вся неизменяемость реестра обесценивается одним выключенным сервером. Ответ — контентная адресация и сети, которые её реализуют.

Децентрализованные хранилища: IPFS, Filecoin, Arweave, контентная адресация

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов