Стандарт ERC-6909 является альтернативой стандарту ERC-1155: Multi Token Standard для управления множеством токенов из одного смарт-контракта.

Основные отличия от ERC-1155:

  • Интерфейс не требует реализации callback механизма для получателя токена.

  • Нет возможности делать batch вызовы, когда в одной транзакции происходит несколько операций с токенами.

  • Переработана система выдачи разрешений на использование токенов третьим лицам (апрувов).

Интерфейс ERC-6909 представляет собой минимальную функциональность, что позволяет сократить издержки в размере кода смарт-контракта и исполнении вызова транзакций.

Важно! Любой смарт-контракт, который будет реализовывать ERC-6909, должен поддерживать ERC-165: Standard Interface Detection по умолчанию.

Интересно! В разработке стандарта принял участие Vectorized, разработчик таких проектов, как soladyERC721Amulticaller.

Референсная имплементация

Референсная имплементация взята из спецификации ERC-6909 и упрощена мной для быстрого ознакомления. Добавлены комментарии к коду. Рекомендую изучить сначала референс, а потом читать статью дальше.

contract ERC6909 {
    /// @notice Баланс владельцев
    mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;

    /// @notice Выданные разрешения на использование токена третьим лицам
    mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;

    /// @notice Разрешения для операторов. Дает больше полномочий на управление всеми токенам владельца
    mapping(address owner => mapping(address spender => bool)) public isOperator;

    /// @notice Трансфер токена от имени владельца
    function transfer(address receiver, uint256 id, uint256 amount) public returns (bool) {
        if (balanceOf[msg.sender][id] < amount) revert InsufficientBalance(msg.sender, id);

        balanceOf[msg.sender][id] -= amount;
        balanceOf[receiver][id] += amount;

        emit Transfer(msg.sender, msg.sender, receiver, id, amount);
        return true;
    }

    /// @notice Трансфер токена третьими лицами, требует выданного разрешения
    function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool) {
        if (sender != msg.sender && !isOperator[sender][msg.sender]) {
            uint256 senderAllowance = allowance[sender][msg.sender][id];

            if (senderAllowance < amount) revert InsufficientPermission(msg.sender, id);
            if (senderAllowance != type(uint256).max) {
                allowance[sender][msg.sender][id] = senderAllowance - amount;
            }
        }

        if (balanceOf[sender][id] < amount) revert InsufficientBalance(sender, id);

        balanceOf[sender][id] -= amount;
        balanceOf[receiver][id] += amount;

        emit Transfer(msg.sender, sender, receiver, id, amount);
        return true;
    }

    /// @notice Выдача разрешения на передачу токена третьими лицами с ограничением количества токена
    function approve(address spender, uint256 id, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender][id] = amount;
        emit Approval(msg.sender, spender, id, amount);
        return true;
    }

    /// @notice Выдача разрешения на передачу токена оператором без ограничения на количество токена
    function setOperator(address spender, bool approved) public returns (bool) {
        isOperator[msg.sender][spender] = approved;
        emit OperatorSet(msg.sender, spender, approved);
        return true;
    }

    function _mint(address receiver, uint256 id, uint256 amount) internal {
      balanceOf[receiver][id] += amount;
      emit Transfer(msg.sender, address(0), receiver, id, amount);
    }

    function _burn(address sender, uint256 id, uint256 amount) internal {
      balanceOf[sender][id] -= amount;
      emit Transfer(msg.sender, sender, address(0), id, amount);
    }
}

Дальше будем детально смотреть на изменения относительно стандарта ERC-1155.

Изменение структуры хранения балансов

Структура хранения балансов - это первое на что необходимо обратить внимание. В отличие от ERC-1155 есть изменения.

// ERC-1155 из OpenZeppelin
mapping(uint256 id => mapping(address account => uint256)) private _balances;

// ERC-6909 из OpenZeppelin
mapping(address owner => mapping(uint256 id => uint256)) private _balances;

Маппинг отвечающий за хранение баланса аккаунта начинается не с идентификатора, а с адреса аккаунта владельца.

Принципиально это влияет только на интерфейс взаимодействия. По прежнему, чтобы получить все балансы пользователя необходимо самостоятельно реализовывать дополнительные функции на смарт-контракте или индексировать данные off-chain. Связано это с тем, что нужно знать все идентификаторы токенов, которыми владеет аккаунт, а базовая имплементация не хранит этой информации по дефолту.

Нет обратного вызова (callback)

Согласно стандарту ERC-1155 смарт-контракт, выступающий получателем токенов должен реализовывать интерфейс ERC1155TokenReceiver.

Этот интерфейс диктует обязательную реализацию одной из функции согласно выбранному способу передачи токенов (single или batch):

-  function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) external returns(bytes4);
-  function onERC1155BatchReceived(address _operator, address _from, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external returns(bytes4);

В ERC-6909 разработчикам по прежнему можно использовать обратные вызовы, но только реализация остается на их стороне и может быть произвольной.

ERC-6909 не регламентирует механизм обратного вызова. Это позволяет экономить на размере базовой имплементации смарт-контракта и количестве операций в момент исполнения, что является эффективным с точки зрения газа и сложности.

Изменения в трансфере токенов

Аналогично обратным вызовам, стандарт поступил с batch операциями.

ERC-1155 требует реализации дополнительных функций:

- function safeBatchTransferFrom(address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external;
- function balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids) external view returns (uint256[] memory);

ERC-6909 больше не регламентирует batch операции и не требует их реализации только ради того, чтобы быть совместимым стандарту.

Batch операции могут быть добавлены по усмотрению разработчика и адаптированы под конкретные задачи проекта.

Функции трансфера токена

Трансфер максимально приближен к реализации стандарта ERC-20 c небольшой модификации.

- function transfer(address receiver, uint256 id, uint256 amount) public returns (bool);
- function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool);

Sender и receiver - это привычные from и to. Добавляется id, для возможности указать идентификатор токена, который участвует в переводе. Добавление id очень напоминает ERC-721 и ERC-1155.

Гибкая система выдачи апрува

В ERС-1155 выдать апрув можно только оператору через вызов функции:

function setApprovalForAll(address _operator, bool _approved) external;

В ERC-6909 вводится гибридная система выдачи апрувов. Есть две возможности выдать апрув:

  • Оператору с указанием неограниченного для использования количества токенов от имени пользователя. При этом оператор может управлять токеном пользователя (с любым id).

  • Произвольному аккаунту с ограничением количества токенов, которое он сможет использовать от имени пользователя.

Таким образом интерфейс ERC-6909 предоставляет две функции для реализации работы с апрувом:

- function setOperator(address spender, bool approved) public returns (bool);
- function approve(address spender, uint256 id, uint256 amount) public returns (bool);

Подобный механизм достаточно гибок, но есть нюанс для случаев, когда аккаунту выдается апрув через обе функции. В таком случае стандарт реализует проверки в след��ющей очередности:

  1. Проверка на оператора.

  2. Если не оператор, то проверка allowance, выданного через вызов approve().

Проверяется выданный апрув только при использовании функции transferFrom().

function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool) {
    // Если сендер сам отправляет токены или сендеру выданы права оператора, то тогда сразу отправить токены
    // В противном случае, скорректировать значение суммы, остающейся в распоряжение вызывающего
    if (sender != msg.sender && !isOperator[sender][msg.sender]) {
        uint256 senderAllowance = allowance[sender][msg.sender][id];
        if (senderAllowance < amount) revert InsufficientPermission(msg.sender, id);

        if (senderAllowance != type(uint256).max) {
            allowance[sender][msg.sender][id] = senderAllowance - amount;
        }
    }
  
    // Изменение балансов, отправка события
    ...

    return true;
}

Таким образом, для оператора, которому выдан апрув на ограниченную сумму (через функцию approve()), не будет изменяться allowance.

Metadata токенов

При помощи стандарта ERC-6909 можно реализовывать взаимозаменяемые токены и невзаимозаменяемые одновременно. Реализация метаданных таких токенов выносится за пределы основного стандарта в отдельное расширение и является опциональной.

Почему опциональной? Ответ прост, для реализации управления LP токенами (или другими видами токенов) может быть не важен их namesymbol или URI. Именно поэтому метаданные опциональны и вынесены из базовой реализации, но использование регламентировано.

При этом, в настоящее время, только библиотека OpenZeppelin реализует метаданные как расширение к стандарту. (В solmate нет смарт-контрактов для метаданных, в solady метаданные зашиты в базовую имплементацию).

Дальше посмотрим на то, как смарт-контракты метаданных реализованы в OpenZeppelin.

Важно! На момент написания статьи, все, что касается ERC-6909 в OpenZeppelin, помечено, как draft.

ERC6909Metadata.sol

contract ERC6909Metadata {
    struct TokenMetadata {
        string name;
        string symbol;
        uint8 decimals;
    }

    mapping(uint256 id => TokenMetadata) private _tokenMetadata;

    function name(uint256 id) public view virtual returns (string memory) {
        return _tokenMetadata[id].name;
    }

    function symbol(uint256 id) public view virtual override returns (string memory) {
        return _tokenMetadata[id].symbol;
    }

    function decimals(uint256 id) public view virtual override returns (uint8) {
        return _tokenMetadata[id].decimals;
    }
}

Для нас здесь интересно две вещи:

  1. Все функции name()symbol()decimals() принимают один аргумент id. Это означает, что каждый токен будет иметь собственные параметры.

  2. OpenZeppelin использует комбинацию mapping и structure для хранения данных. Классический подход для оптимизации хранения данных.

ERC6909TokenSupply.sol

contract ERC6909TokenSupply {
    mapping(uint256 id => uint256) private _totalSupplies;

    function totalSupply(uint256 id) public view virtual override returns (uint256) {
        return _totalSupplies[id];
    }

    /// @dev Override the `_update` function to update the total supply of each token id as necessary.
    function _update(address from, address to, uint256 id, uint256 amount) internal virtual override {
      ...
    }
}

Total supply аналогично name, symbol и так далее индивидуален для каждого токена.

ERC6909ContentURI.sol

contract ERC6909ContentURI is ERC6909, IERC6909ContentURI {
    string private _contractURI;
    mapping(uint256 id => string) private _tokenURIs;

    function contractURI() public view virtual override returns (string memory) {
        return _contractURI;
    }

    function tokenURI(uint256 id) public view virtual override returns (string memory) {
        return _tokenURIs[id];
    }
}

Этот контракт хранит метаданные необходимые для нфт. contractURI для объявления общих данных коллекции. tokenURI для объявления индивидуальных метаданных по каждому токену.

Таким образом, используя комбинации расширений смарт-контрактов метаданных: ERC6909Metadata, ERC6909TokenSupply, ERC6909ContentURI, стандарт может одновременно управлять, как взаимозаменяемыми токенами, так и невзаимозаменяемыми.

Удаление safe именования

Соглашения об именовании safeTransfer() и safeTransferFrom() вводят в заблуждение, особенно в контексте стандартов ERC-1155 и ERC-721, так как они требуют внешних вызовов на адресе получателей (если получатель смарт-контракт). Таким образом поток выполнения передается произвольному контракту.

Согласно стандарту ERC-6909 считается, что удаление слова "safe" из всех имен функций больше не будет вводить в заблуждение и одновременно будет являться упрощением внутренней логики работы кода.

Реальное применение

В отличии от множества предлагаемых стандартов токенов, ERC-6909 был сходу опробован в Uniswap четвертой версии.

ERC-6909 выступает в качестве доказательства наличия активов у пользователя внутри протокола. Для этого основной смарт-контракт PoolManager.sol является ERC-6909 токеном.

/// @notice Holds the state for all pools
contract PoolManager is ERC6909Claims {
  ...
}

Работает достаточно просто, после совершения операции (свап, удаление ликвидности) пользователь может оставить свой актив внутри протокола, а взамен получить ERC-6909. В следующий раз, для использования актива внутри протокола достаточно будет сжечь эквивалент ERC-6909.

Например, пользователь обменивает USDT на USDC. Отдает USDT, но за место получения USDC минтит себе эквивалент ERC-6909. USDC остается внутри протокола. Через некоторое время пользователь решает вернуть USDT обратно, обменяв его на USDC. Для этого ему достаточно сжечь ERC-6909 и протокол вернет ему USDT.

Таким образом, ERC-6909 позволяет существенно экономить на газе при перемещение активов. Минт ERC-6909 дешевле, чем трансфер USDC по количеству газа. Это так, потому что минт - это одна запись в сторадже смарт-контракта и одно событие Mint(), а большинство токенов добавляют дополнительные проверки при трансфере, от whitelists до другой кастомной логики.

Особенно выгодно это становится:

  • пользователям, которые делает множество операций за короткий промежуток времени

  • поставщикам ликвидности, которые занимаются ребалансировкой своих позиций

Подробнее в официальной документации Uniswap.

Вывод

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

Стандарт ERC-6909 не ��вляется обратно совместимым с ERC-1155! Это нужно понимать и учитывать. Селекторы функций разные.

При этом особый акцент я хотел бы сделать на возможности комбинировать взаимозаменяемые и невзаимозаменяемые токены. Один смарт-контракт ERC-6909 может управлять ERC-20 токенами и нфтишками легко и непринужденно.

Важно! С оговоркой, что все нфт будут реализованы в рамках одной коллекции, так как contractURI() функция не подразумевает поддержку множества коллекций.

Links

  1. ERC-6909: Minimal Multi-Token Interface

  2. Имплементация в solady

  3. Имплементация в solmate

  4. Имплементация в OpenZeppelin

  5. ERC-6909 Minimal Multi-Token Standard от RareSkills

Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.

Комментарии (1)


  1. RetroStyle
    31.10.2025 09:01

    "Отдает USDT, но за место получения USDC минтит себе эквивалент ERC-6909. "

    Ничего не понимаю, что такое "место получения"? Бессмыслица какая-то.