Przejdź do głównej treści

ERC-20 z zabezpieczeniami

erc-20
Początkujący
Ori Pomerantz
15 sierpnia 2022
8 minut czytania

Wprowadzenie

Jedną z wspaniałych rzeczy w Ethereum jest to, że nie ma centralnego organu, który mógłby modyfikować lub cofać twoje transakcje. Jednym z wielkich problemów z Ethereum jest to, że nie ma centralnego organu z uprawnieniami do cofania błędów użytkowników lub nielegalnych transakcji. W tym artykule dowiesz się o niektórych typowych błędach popełnianych przez użytkowników w przypadku tokenów ERC-20, a także o tym, jak tworzyć kontrakty ERC-20, które pomagają użytkownikom unikać tych błędów lub dają centralnemu organowi pewną władzę (na przykład do zamrażania kont).

Zauważ, że chociaż będziemy używać kontraktu tokena ERC-20 OpenZeppelin (opens in a new tab), ten artykuł nie wyjaśnia go w najdrobniejszych szczegółach. Możesz znaleźć te informacje tutaj.

Jeśli chcesz zobaczyć kompletny kod źródłowy:

  1. Otwórz Remix IDE (opens in a new tab).
  2. Kliknij ikonę klonowania z GitHub (clone github icon).
  3. Sklonuj repozytorium GitHub https://github.com/qbzzt/20220815-erc20-safety-rails.
  4. Otwórz contracts > erc20-safety-rails.sol.

Tworzenie kontraktu ERC-20

Zanim będziemy mogli dodać funkcjonalność zabezpieczeń, potrzebujemy kontraktu ERC-20. W tym artykule użyjemy kreatora kontraktów OpenZeppelin (OpenZeppelin Contracts Wizard) (opens in a new tab). Otwórz go w innej przeglądarce i postępuj zgodnie z poniższymi instrukcjami:

  1. Wybierz ERC20.

  2. Wprowadź następujące ustawienia:

    ParametrWartość
    NameSafetyRailsToken
    SymbolSAFE
    Premint1000
    FeaturesNone
    Access ControlOwnable
    UpgradabilityNone
  3. Przewiń w górę i kliknij Open in Remix (dla Remix) lub Download, aby użyć innego środowiska. Zakładam, że używasz Remix, jeśli używasz czegoś innego, po prostu wprowadź odpowiednie zmiany.

  4. Mamy teraz w pełni funkcjonalny kontrakt ERC-20. Możesz rozwinąć .deps > npm, aby zobaczyć zaimportowany kod.

  5. Skompiluj, wdróż i pobaw się kontraktem, aby zobaczyć, że działa on jako kontrakt ERC-20. Jeśli musisz dowiedzieć się, jak korzystać z Remix, skorzystaj z tego samouczka (opens in a new tab).

Częste błędy

Błędy

Użytkownicy czasami wysyłają tokeny na zły adres. Chociaż nie potrafimy czytać w ich myślach, aby wiedzieć, co zamierzali zrobić, istnieją dwa rodzaje błędów, które zdarzają się często i są łatwe do wykrycia:

  1. Wysyłanie tokenów na własny adres kontraktu. Na przykład token OP sieci Optimism (opens in a new tab) zdołał zgromadzić ponad 120 000 (opens in a new tab) tokenów OP w mniej niż dwa miesiące. Stanowi to znaczną kwotę, którą prawdopodobnie ludzie po prostu stracili.

  2. Wysyłanie tokenów na pusty adres, taki, który nie odpowiada kontu zewnętrznemu (EOA) ani inteligentnemu kontraktowi. Chociaż nie mam statystyk, jak często to się zdarza, jeden incydent mógł kosztować 20 000 000 tokenów (opens in a new tab).

Zapobieganie transferom

Kontrakt ERC-20 OpenZeppelin zawiera hook (zaczep), _beforeTokenTransfer (opens in a new tab), który jest wywoływany przed transferem tokena. Domyślnie ten hook nic nie robi, ale możemy podpiąć pod niego własną funkcjonalność, taką jak kontrole, które powodują wycofanie (revert), jeśli wystąpi problem.

Aby użyć hooka, dodaj tę funkcję po konstruktorze:

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

Niektóre części tej funkcji mogą być nowe, jeśli nie znasz zbyt dobrze Solidity:

        internal virtual

Słowo kluczowe virtual oznacza, że tak jak odziedziczyliśmy funkcjonalność po ERC20 i nadpisaliśmy tę funkcję, inne kontrakty mogą dziedziczyć po nas i nadpisywać tę funkcję.

        override(ERC20)

Musimy jawnie określić, że nadpisujemy (opens in a new tab) definicję _beforeTokenTransfer tokena ERC20. Ogólnie rzecz biorąc, jawne definicje są o wiele lepsze z punktu widzenia bezpieczeństwa niż niejawne - nie możesz zapomnieć, że coś zrobiłeś, jeśli masz to tuż przed oczami. To jest również powód, dla którego musimy określić, której nadklasy _beforeTokenTransfer nadpisujemy.

        super._beforeTokenTransfer(from, to, amount);

Ta linia wywołuje funkcję _beforeTokenTransfer kontraktu lub kontraktów, z których dziedziczymy, a które ją posiadają. W tym przypadku jest to tylko ERC20, Ownable nie ma tego hooka. Mimo że obecnie ERC20._beforeTokenTransfer nic nie robi, wywołujemy go na wypadek, gdyby funkcjonalność została dodana w przyszłości (a my zdecydujemy się ponownie wdrożyć kontrakt, ponieważ kontrakty nie zmieniają się po wdrożeniu).

Kodowanie wymagań

Chcemy dodać do funkcji następujące wymagania:

  • Adres to nie może być równy address(this), czyli adresowi samego kontraktu ERC-20.
  • Adres to nie może być pusty, musi to być:
    • Konto zewnętrzne (EOA). Nie możemy bezpośrednio sprawdzić, czy adres jest EOA, ale możemy sprawdzić saldo ETH adresu. EOA prawie zawsze mają saldo, nawet jeśli nie są już używane - trudno jest je wyczyścić do ostatniego wei.
    • Inteligentny kontrakt. Sprawdzenie, czy adres jest inteligentnym kontraktem, jest nieco trudniejsze. Istnieje kod operacji, który sprawdza długość zewnętrznego kodu, zwany EXTCODESIZE (opens in a new tab), ale nie jest on dostępny bezpośrednio w Solidity. Musimy do tego użyć Yul (opens in a new tab), czyli asemblera EVM. Istnieją inne wartości, których moglibyśmy użyć z Solidity (<address>.code i <address>.codehash (opens in a new tab)), ale kosztują one więcej gazu.

Przeanalizujmy nowy kod linijka po linijce:

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

To jest pierwsze wymaganie, sprawdzenie, czy to i this(address) nie są tym samym.

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

W ten sposób sprawdzamy, czy adres jest kontraktem. Nie możemy otrzymać wyniku bezpośrednio z Yul, więc zamiast tego definiujemy zmienną do przechowywania wyniku (w tym przypadku isToContract). Sposób działania Yul polega na tym, że każdy kod operacji jest uważany za funkcję. Więc najpierw wywołujemy EXTCODESIZE (opens in a new tab), aby uzyskać rozmiar kontraktu, a następnie używamy GT (opens in a new tab), aby sprawdzić, czy nie jest on zerem (mamy do czynienia z liczbami całkowitymi bez znaku, więc oczywiście nie może być ujemny). Następnie zapisujemy wynik do isToContract.

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

I wreszcie, mamy właściwe sprawdzenie pustych adresów.

Dostęp administracyjny

Czasami przydatne jest posiadanie administratora, który może cofać błędy. Aby zmniejszyć potencjał nadużyć, tym administratorem może być multisig (opens in a new tab), dzięki czemu wiele osób musi zgodzić się na działanie. W tym artykule będziemy mieli dwie funkcje administracyjne:

  1. Zamrażanie i odmrażanie kont. Może to być przydatne na przykład wtedy, gdy konto mogło zostać przejęte.

  2. Czyszczenie aktywów.

    Czasami oszuści wysyłają fałszywe tokeny do kontraktu prawdziwego tokena, aby zyskać wiarygodność. Na przykład zobacz tutaj (opens in a new tab). Prawdziwy kontrakt ERC-20 to 0x4200....0042 (opens in a new tab). Oszustwo, które udaje, że nim jest, to 0x234....bbe (opens in a new tab).

    Możliwe jest również, że ludzie przez pomyłkę wyślą legalne tokeny ERC-20 do naszego kontraktu, co jest kolejnym powodem, dla którego warto mieć sposób na ich wydobycie.

OpenZeppelin zapewnia dwa mechanizmy umożliwiające dostęp administracyjny:

Dla uproszczenia w tym artykule używamy Ownable.

Zamrażanie i odmrażanie kontraktów

Zamrażanie i odmrażanie kontraktów wymaga kilku zmian:

  • Mapowanie (opens in a new tab) z adresów na wartości logiczne (boolean) (opens in a new tab), aby śledzić, które adresy są zamrożone. Wszystkie wartości są początkowo zerowe, co dla wartości logicznych jest interpretowane jako fałsz. Tego właśnie chcemy, ponieważ domyślnie konta nie są zamrożone.

        mapping(address => bool) public frozenAccounts;
    
  • Zdarzenia (opens in a new tab), aby poinformować wszystkich zainteresowanych, kiedy konto zostanie zamrożone lub odmrożone. Technicznie rzecz biorąc, zdarzenia nie są wymagane do tych działań, ale pomagają kodowi pozałańcuchowemu nasłuchiwać tych zdarzeń i wiedzieć, co się dzieje. Uważa się za dobry zwyczaj, aby inteligentny kontrakt emitował je, gdy dzieje się coś, co może być istotne dla kogoś innego.

    Zdarzenia są indeksowane, więc możliwe będzie wyszukanie wszystkich przypadków zamrożenia lub odmrożenia konta.

      // Gdy konta są zamrażane lub odmrażane
      event AccountFrozen(address indexed _addr);
      event AccountThawed(address indexed _addr);
    
  • Funkcje do zamrażania i odmrażania kont. Te dwie funkcje są prawie identyczne, więc omówimy tylko funkcję zamrażania.

        function freezeAccount(address addr)
          public
          onlyOwner
    

    Funkcje oznaczone jako public (opens in a new tab) mogą być wywoływane z innych inteligentnych kontraktów lub bezpośrednio przez transakcję.

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

    Jeśli konto jest już zamrożone, następuje wycofanie (revert). W przeciwnym razie zamroź je i wyemituj (emit) zdarzenie.

  • Zmień _beforeTokenTransfer, aby zapobiec przenoszeniu środków z zamrożonego konta. Zauważ, że środki nadal mogą być transferowane na zamrożone konto.

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

Czyszczenie aktywów

Aby uwolnić tokeny ERC-20 przechowywane przez ten kontrakt, musimy wywołać funkcję w kontrakcie tokena, do którego należą, albo transfer (opens in a new tab), albo approve (opens in a new tab). W tym przypadku nie ma sensu marnować gazu na uprawnienia (allowances), równie dobrze możemy wykonać transfer bezpośrednio.

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

To jest składnia do tworzenia obiektu dla kontraktu, gdy otrzymamy adres. Możemy to zrobić, ponieważ mamy definicję tokenów ERC20 jako część kodu źródłowego (patrz linia 4), a ten plik zawiera definicję IERC20 (opens in a new tab), interfejsu dla kontraktu ERC-20 OpenZeppelin.

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

To jest funkcja czyszcząca, więc prawdopodobnie nie chcemy zostawiać żadnych tokenów. Zamiast ręcznie pobierać saldo od użytkownika, równie dobrze możemy zautomatyzować ten proces.

Podsumowanie

Nie jest to idealne rozwiązanie - nie ma idealnego rozwiązania problemu „użytkownik popełnił błąd”. Jednak stosowanie tego rodzaju kontroli może przynajmniej zapobiec niektórym błędom. Możliwość zamrażania kont, choć niebezpieczna, może być wykorzystana do ograniczenia szkód wynikających z niektórych ataków hakerskich poprzez odmowę hakerowi dostępu do skradzionych środków.

Zobacz tutaj więcej moich prac (opens in a new tab).