Раздел 30 · Web3 на Rust

Стандарты токенов: ERC-20 и его родня

senior~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Стандарты токенов: ERC-20 и его родня

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

Идея

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

Минимальный ERC-20 это одна табличка балансов плюс договорённость об именах:

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

contract MiniToken {
    string public name = "Mini";
    string public symbol = "MINI";
    uint8 public 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 initial) {
        totalSupply = initial;
        balanceOf[msg.sender] = initial;
        emit Transfer(address(0), msg.sender, initial); // чеканка это перевод из нуля
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount; // 0.8 сам откатит при недостатке средств
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }
}

Заметь: токен это не отдельная монета, а строчка в mapping внутри одного контракта. «Перевести токен» значит уменьшить одно число и увеличить другое. Событие Transfer из урока 27 это то, по чему кошельки видят движение средств.

approve и allowance: разрешить потратить за тебя

Как дать бирже право снять твои токены при свопе, не отдавая ей ключи? Через двухшаговый паттерн allowance:

  1. approve(spender, amount) выставляет лимит: «этому адресу можно снять до столько».
  2. transferFrom(owner, to, amount) вызывает уже сам spender, пока лимит не исчерпан.
function approve(address spender, uint256 amount) external returns (bool) {
    allowance[msg.sender][spender] = amount;
    emit Approval(msg.sender, spender, amount);
    return true;
}

function transferFrom(address from, address to, uint256 amount) external returns (bool) {
    allowance[from][msg.sender] -= amount; // тратим из разрешения, откат при превышении
    balanceOf[from] -= amount;
    balanceOf[to] += amount;
    emit Transfer(from, to, amount);
    return true;
}

Это фундамент композируемости DeFi: разрешил протоколу тратить твои токены, и он действует от твоего имени в рамках лимита. Весь DEX, который мы соберём дальше в курсе, стоит на этом.

Невзаимозаменяемые и мульти: ERC-721, ERC-1155, ERC-4626

ERC-20 задал шаблон, и от него отпочковалась семья:

  • ERC-721 это NFT: каждый токен уникален, у него свой tokenId и один владелец. Вместо балансов карта tokenId → owner. Метаданные (картинка, свойства) лежат по ссылке из tokenURI.
  • ERC-1155 это мульти-токен: один контракт держит сразу много видов токенов и умеет пакетные переводы, экономя газ. Родом из игр, где предметов тысячи.
  • ERC-4626 это токенизированное хранилище: вносишь актив, получаешь доли, которые растут по мере дохода протокола. Стандарт доходных стратегий DeFi.

Чтобы слова про ERC-721 не остались словами, вот ядро его интерфейса. Оно выглядит знакомо после ERC-20, но обрати внимание: везде гуляет tokenId, а не количество.

interface IERC721 {
    // третий indexed это tokenId: переводится конкретный токен, а не сумма
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);

    function balanceOf(address owner) external view returns (uint256); // сколько NFT у адреса
    function ownerOf(uint256 tokenId) external view returns (address); // кто владеет токеном
    function transferFrom(address from, address to, uint256 tokenId) external;
}

Про ERC-4626 стоит рассказать, зачем ему вообще стандартизация, на живой атаке. Хранилище выдаёт доли пропорционально активам, и на пустом хранилище пропорция вырождается. Атакующий первым кладёт 1 wei и получает 1 долю, а потом переводит крупную сумму на адрес хранилища напрямую, обычным transfer, мимо deposit: активов стало много, а долей по-прежнему одна. Теперь честный вкладчик вносит депозит, его доля вычисляется делением с округлением вниз и получается ноль (или почти ноль), и атакующий, владея единственной долей, забирает и свой донат, и чужой депозит. Это и есть inflation-атака. Стандарт с эталонной реализацией решает её системно: виртуальные доли и сдвиг десятичных знаков (virtual shares, decimals offset) делают так, будто в хранилище всегда есть немного активов и долей, и раздуть цену одной доли донатом становится экономически бессмысленно. Когда каждый протокол писал свой vault сам, каждый наступал на эти грабли заново.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.