К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

ERC-20 с нуля: пишем токен на Solidity и разбираем каждую строку

Минимальная реализация стандарта ERC-20 на Solidity: балансы, переводы, allowance, события, типичные ошибки и почему в продакшене нужны проверенные библиотеки.

4 мин чтенияОбновлено:
В этой статье

Токен ERC-20 кажется магией, пока не увидишь исходный код: это маленький контракт с двумя таблицами и тремя функциями. Здесь мы напишем его целиком и разберём каждую часть. Понятийный обзор - в статье токены и NFT, механика approve - в отдельном разборе. Синтаксис - в обзоре Solidity.

Что требует стандарт

ERC-20 определяет интерфейс: функции totalSupply, balanceOf, transfer, approve, allowance, transferFrom и события Transfer, Approval. Необязательные: name, symbol, decimals.

Полный код

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract SimpleToken {
    string public name = "Simple Token";
    string public symbol = "SIMP";
    uint8 public constant decimals = 18;
    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);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        _mint(msg.sender, initialSupply);
    }

    function transfer(address to, uint256 value) external returns (bool) {
        _transfer(msg.sender, to, value);
        return true;
    }

    function approve(address spender, uint256 value) external returns (bool) {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {
            require(allowed >= value, "allowance");
            allowance[from][msg.sender] = allowed - value;
        }
        _transfer(from, to, value);
        return true;
    }

    function _transfer(address from, address to, uint256 value) internal {
        require(to != address(0), "zero address");
        require(balanceOf[from] >= value, "balance");
        balanceOf[from] -= value;
        balanceOf[to] += value;
        emit Transfer(from, to, value);
    }

    function _mint(address to, uint256 value) internal {
        totalSupply += value;
        balanceOf[to] += value;
        emit Transfer(address(0), to, value);
    }
}

Разбор по частям

Две таблицы

  • balanceOf[адрес] - сколько токенов у адреса.
  • allowance[владелец][тратящий] - сколько владелец разрешил тратить.

Это всё «состояние» токена. Токены не «лежат» в кошельках - это записи в balanceOf (что хранится в блокчейне). Таблицы хранятся в слотах по ключу.

Знаки после запятой

decimals = 18 значит, что в 1000000000000000000 единиц - один «целый» токен. Контракт работает только с целыми числами; десятичную точку рисует интерфейс (почему). У USDC decimals = 6, у многих токенов 18, и это надо учитывать при расчётах.

transfer

Простой перевод: проверили баланс, уменьшили у отправителя, увеличили у получателя, записали событие. Селектор функции transfer(address,uint256) равен 0xa9059cbb (что такое селектор).

approve и transferFrom

Двухшаговая схема «разрешил - потратили»:

  1. Владелец вызывает approve(spender, value). В таблице allowance появляется запись.
  2. Контракт (spender) вызывает transferFrom(владелец, получатель, value). Он проверяет разрешение, уменьшает его и выполняет перевод.

Именно так наш эскроу забирает токены покупателя: покупатель сначала одобряет сумму, затем контракт списывает её в createOffer или acceptOffer.

Строка if (allowed != type(uint256).max) - распространённое соглашение: «бесконечное» разрешение не уменьшается, это экономит газ. Но опасно: контракт может потратить все токены (риски).

События

Transfer и Approval - то, по чему кошельки и обозреватели строят историю. Правило: при создании токенов событие Transfer идёт от нулевого адреса (address(0)), при сжигании - к нулевому. Хеш сигнатуры Transfer(address,address,uint256) - первая тема события, начинается с 0xddf252ad (о журнале событий).

Проверка нулевого адреса

require(to != address(0)) защищает от случайной отправки «в никуда», когда токены навсегда теряются (хотя на практике «сожжённые» токены часто отправляют на нулевой или специальный адрес намеренно).

Что здесь упрощено

В минимальной реализации нет:

  • Защиты от гонки в approve. Если сменить разрешение с 100 на 50, получатель может успеть потратить 100 до изменения, а потом ещё 50. Решение: сначала обнулять или использовать increaseAllowance.
  • Permit (EIP-2612). Разрешение по подписи без отдельной транзакции (подписи).
  • Управления выпуском. Кто может создавать токены и как - важнейшая часть дизайна (контроль доступа).
  • Пауз и блокировок адресов, которые есть у централизованных стейблкоинов.

Нестандартные токены в реальности

Реальные токены нарушают стандарт по-разному:

  • USDT на некоторых сетях не возвращает bool из transfer. Контракты должны использовать SafeERC20, который принимает оба варианта.
  • Токены с комиссией за перевод (fee-on-transfer) доставляют меньше, чем отправлено. Поэтому эскроу сверяет полученную сумму с заявленной и отклоняет такие токены.
  • Ребейзинговые токены сами меняют балансы.
  • Блокировки адресов (USDC, USDT): перевод конкретному адресу может отклоняться. В эскроу в этом случае выплата откладывается «кредитом».

Разбор всех подводных камней: особенности ERC-20.

Как проверить свой токен

  1. Написать тесты: переводы, нулевые суммы, недостаточный баланс, transferFrom без разрешения (как тестировать).
  2. Фаззинг: сумма всех балансов всегда равна totalSupply.
  3. Сравнить с реализацией OpenZeppelin.
  4. Заказать аудит перед запуском на реальные деньги.

Что почитать дальше

Идём к безопасности: каталог уязвимостей. А чтобы запустить собственные тесты, - руководство по Foundry.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.