Перейти к основному контенту

ERC-20 с защитными механизмами

erc-20
Для начинающих
Ори Померанц
15 августа 2022 г.
8 минут на чтение

Введение

Одно из главных преимуществ Эфириума заключается в том, что не существует центрального органа, который мог бы изменять или отменять ваши транзакции. Одна из главных проблем Эфириума заключается в том, что не существует центрального органа, обладающего полномочиями отменять ошибки пользователей или незаконные транзакции. В этой статье вы узнаете о некоторых распространенных ошибках, которые пользователи совершают с токенами ERC-20, а также о том, как создавать контракты ERC-20, которые помогают пользователям избегать этих ошибок или наделяют центральный орган определенными полномочиями (например, для заморозки аккаунтов).

Обратите внимание, что хотя мы будем использовать контракт токена ERC-20 от ОпенЗеппелин (открывается в новой вкладке), в этой статье он не рассматривается в мельчайших подробностях. Вы можете найти эту информацию здесь.

Если вы хотите посмотреть полный исходный код:

  1. Откройте Remix IDE (открывается в новой вкладке).
  2. Нажмите на значок клонирования GitHub (clone github icon).
  3. Клонируйте репозиторий GitHub https://github.com/qbzzt/20220815-erc20-safety-rails.
  4. Откройте contracts > erc20-safety-rails.sol.

Создание контракта ERC-20

Прежде чем мы сможем добавить защитные механизмы, нам нужен контракт ERC-20. В этой статье мы будем использовать OpenZeppelin Contracts Wizard (открывается в новой вкладке). Откройте его в другом браузере и следуйте этим инструкциям:

  1. Выберите ERC20.

  2. Введите следующие настройки:

    ПараметрЗначение
    ИмяSafetyRailsToken
    СимволSAFE
    Предварительный выпуск (Premint)1000
    ФункцииНет
    Контроль доступаOwnable
    Возможность обновленияНет
  3. Прокрутите вверх и нажмите Open in Remix (для Remix) или Download, чтобы использовать другую среду. Я буду исходить из того, что вы используете Remix; если вы используете что-то другое, просто внесите соответствующие изменения.

  4. Теперь у нас есть полностью функциональный контракт ERC-20. Вы можете развернуть .deps > npm, чтобы увидеть импортированный код.

  5. Скомпилируйте, разверните и поэкспериментируйте с контрактом, чтобы убедиться, что он работает как контракт ERC-20. Если вам нужно узнать, как использовать Remix, воспользуйтесь этим руководством (открывается в новой вкладке).

Распространенные ошибки

Ошибки

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

  1. Отправка токенов на собственный адрес контракта. Например, токен OP от Optimism (открывается в новой вкладке) умудрился накопить более 120 000 (открывается в новой вкладке) токенов OP менее чем за два месяца. Это представляет собой значительную сумму средств, которую люди, предположительно, просто потеряли.

  2. Отправка токенов на пустой адрес, который не соответствует внешне принадлежащему аккаунту (EOA) или смарт-контракту. Хотя у меня нет статистики о том, как часто это происходит, один инцидент мог стоить 20 000 000 токенов (открывается в новой вкладке).

Предотвращение переводов

Контракт ERC-20 от ОпенЗеппелин включает хук _beforeTokenTransfer (открывается в новой вкладке), который вызывается перед переводом токена. По умолчанию этот хук ничего не делает, но мы можем привязать к нему собственную функциональность, например, проверки, которые вызывают откат в случае проблемы.

Чтобы использовать хук, добавьте эту функцию после конструктора:

    function _beforeTokenTransfer(address from, address to, uint256 amount)
        internal virtual
        override(ERC20)
    {
        super._beforeTokenTransfer(from, to, amount);
    }

Некоторые части этой функции могут быть новыми, если вы не очень хорошо знакомы с Solidity:

        internal virtual

Ключевое слово virtual означает, что так же, как мы унаследовали функциональность от ERC20 и переопределили эту функцию, другие контракты могут наследоваться от нас и переопределять эту функцию.

        override(ERC20)

Мы должны явно указать, что переопределяем (открывается в новой вкладке) определение _beforeTokenTransfer токена ERC-20. В целом, явные определения намного лучше с точки зрения безопасности, чем неявные — вы не сможете забыть, что сделали что-то, если это находится прямо перед вами. Это также причина, по которой нам нужно указать, какой именно суперкласс _beforeTokenTransfer мы переопределяем.

        super._beforeTokenTransfer(from, to, amount);

Эта строка вызывает функцию _beforeTokenTransfer контракта или контрактов, от которых мы унаследовали и в которых она есть. В данном случае это только ERC20, в Ownable этого хука нет. Несмотря на то, что в настоящее время ERC20._beforeTokenTransfer ничего не делает, мы вызываем его на случай, если функциональность будет добавлена в будущем (и мы затем решим повторно развернуть контракт, поскольку контракты не меняются после развертывания).

Программирование требований

Мы хотим добавить в функцию следующие требования:

  • Адрес to не может быть равен address(this), адресу самого контракта ERC-20.
  • Адрес to не может быть пустым, он должен быть либо:
    • Внешне принадлежащим аккаунтом (EOA). Мы не можем напрямую проверить, является ли адрес EOA, но мы можем проверить баланс ETH адреса. EOA почти всегда имеют баланс, даже если они больше не используются — их трудно очистить до последнего Wei.
    • Смарт-контрактом. Проверить, является ли адрес смарт-контрактом, немного сложнее. Существует код операции, который проверяет длину внешнего кода, называемый EXTCODESIZE (открывается в новой вкладке), но он недоступен напрямую в Solidity. Для этого нам нужно использовать Yul (открывается в новой вкладке), который является ассемблером EVM. Есть и другие значения, которые мы могли бы использовать из Solidity (<address>.code и <address>.codehash (открывается в новой вкладке)), но они стоят дороже.

Давайте разберем новый код построчно:

        require(to != address(this), "Can't send tokens to the contract address");

Это первое требование: проверить, что to и this(address) не являются одним и тем же.

        bool isToContract;
        assembly {
           isToContract := gt(extcodesize(to), 0)
        }

Так мы проверяем, является ли адрес контрактом. Мы не можем получить вывод напрямую из Yul, поэтому вместо этого мы определяем переменную для хранения результата (в данном случае isToContract). Yul работает таким образом, что каждый код операции считается функцией. Поэтому сначала мы вызываем EXTCODESIZE (открывается в новой вкладке), чтобы получить размер контракта, а затем используем GT (открывается в новой вкладке), чтобы проверить, что он не равен нулю (мы имеем дело с беззнаковыми целыми числами, поэтому, конечно, он не может быть отрицательным). Затем мы записываем результат в isToContract.

        require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");

И, наконец, у нас есть фактическая проверка на пустые адреса.

Административный доступ

Иногда полезно иметь администратора, который может отменять ошибки. Чтобы снизить вероятность злоупотреблений, этим администратором может быть мультисиг (открывается в новой вкладке), чтобы несколько человек должны были согласовать действие. В этой статье у нас будет две административные функции:

  1. Заморозка и разморозка аккаунтов. Это может быть полезно, например, когда аккаунт может быть скомпрометирован.

  2. Очистка активов.

    Иногда мошенники отправляют поддельные токены на контракт настоящего токена, чтобы получить легитимность. Например, посмотрите здесь (открывается в новой вкладке). Легитимный контракт ERC-20 — это 0x4200....0042 (открывается в новой вкладке). Мошеннический контракт, который выдает себя за него, — это 0x234....bbe (открывается в новой вкладке).

    Также возможно, что люди по ошибке отправляют легитимные токены ERC-20 на наш контракт, что является еще одной причиной иметь способ их вывести.

ОпенЗеппелин предоставляет два механизма для включения административного доступа:

Ради простоты в этой статье мы используем Ownable.

Заморозка и разморозка контрактов

Заморозка и разморозка контрактов требует нескольких изменений:

  • Отображение (mapping) (открывается в новой вкладке) адресов в логические значения (booleans) (открывается в новой вкладке) для отслеживания того, какие адреса заморожены. Все значения изначально равны нулю, что для логических значений интерпретируется как false. Это то, что нам нужно, потому что по умолчанию аккаунты не заморожены.

        mapping(address => bool) public frozenAccounts;
    
  • События (открывается в новой вкладке) для информирования всех заинтересованных лиц о том, когда аккаунт заморожен или разморожен. Технически говоря, события не требуются для этих действий, но это помогает офчейн-коду прослушивать эти события и знать, что происходит. Считается хорошим тоном для смарт-контракта генерировать их, когда происходит что-то, что может быть актуально для кого-то еще.

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

      // Когда аккаунты заморожены или разморожены
      event AccountFrozen(address indexed _addr);
      event AccountThawed(address indexed _addr);
    
  • Функции для заморозки и разморозки аккаунтов. Эти две функции почти идентичны, поэтому мы рассмотрим только функцию заморозки.

        function freezeAccount(address addr)
          public
          onlyOwner
    

    Функции, помеченные как public (открывается в новой вкладке), могут быть вызваны из других смарт-контрактов или напрямую транзакцией.

      {
          require(!frozenAccounts[addr], "Account already frozen");
          frozenAccounts[addr] = true;
          emit AccountFrozen(addr);
      }  // freezeAccount
    

    Если аккаунт уже заморожен, выполнить откат. В противном случае заморозить его и сгенерировать (emit) событие.

  • Изменить _beforeTokenTransfer, чтобы предотвратить перемещение средств с замороженного аккаунта. Обратите внимание, что средства все еще могут быть переведены на замороженный аккаунт.

         require(!frozenAccounts[from], "The account is frozen");
    

Очистка активов

Чтобы высвободить токены ERC-20, удерживаемые этим контрактом, нам нужно вызвать функцию в контракте токена, к которому они принадлежат: либо transfer (открывается в новой вкладке), либо approve (открывается в новой вкладке). В данном случае нет смысла тратить газ на разрешения (allowances), мы можем с таким же успехом перевести их напрямую.

    function cleanupERC20(
        address erc20,
        address dest
    )
        public
        onlyOwner
    {
        IERC20 token = IERC20(erc20);

Это синтаксис для создания объекта контракта, когда мы получаем адрес. Мы можем сделать это, потому что у нас есть определение для токенов ERC-20 как часть исходного кода (см. строку 4), и этот файл включает определение для IERC20 (открывается в новой вкладке), интерфейса для контракта ERC-20 от ОпенЗеппелин.

        uint balance = token.balanceOf(address(this));
        token.transfer(dest, balance);
    }

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

Заключение

Это не идеальное решение — не существует идеального решения проблемы «пользователь совершил ошибку». Однако использование подобных проверок может по крайней мере предотвратить некоторые ошибки. Возможность замораживать аккаунты, хотя и опасна, может быть использована для ограничения ущерба от определенных взломов путем лишения хакера украденных средств.

Смотрите здесь другие мои работы (открывается в новой вкладке).