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

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

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

Введение

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

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

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

  1. Откройте Remix IDE (opens in a new tab).
  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 (opens in a new tab). Откройте его в другом браузере и следуйте этим инструкциям:

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

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

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

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

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

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

Ошибки

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

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

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

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

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

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

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

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

        internal virtual

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

        override(ERC20)

Мы должны явно указать, что переопределяем (opens in a new tab) определение _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 (opens in a new tab), но он недоступен напрямую в Solidity. Для этого нам нужно использовать Yul (opens in a new tab), который является ассемблером EVM. Есть и другие значения, которые мы могли бы использовать из Solidity (<address>.code и <address>.codehash (opens in a new tab)), но они стоят дороже.

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

        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 (opens in a new tab), чтобы получить размер контракта, а затем используем GT (opens in a new tab), чтобы проверить, что он не равен нулю (мы имеем дело с беззнаковыми целыми числами, поэтому, конечно, он не может быть отрицательным). Затем мы записываем результат в isToContract.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        function freezeAccount(address addr)
          public
          onlyOwner
    

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

      {
          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 (opens in a new tab), либо approve (opens in a new tab). В данном случае нет смысла тратить газ на разрешения (allowances), мы можем с таким же успехом перевести их напрямую.

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

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

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

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

Заключение

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

Смотрите здесь другие мои работы (opens in a new tab).