Vai al contenuto principale

ERC-20 con misure di sicurezza

erc-20
Principiante
Ori Pomerantz
15 agosto 2022
9 minuti di lettura

Introduzione

Una delle cose fantastiche di Ethereum è che non esiste un'autorità centrale in grado di modificare o annullare le tue transazioni. Uno dei grandi problemi di Ethereum è che non esiste un'autorità centrale con il potere di annullare gli errori degli utenti o le transazioni illecite. In questo articolo scoprirai alcuni degli errori comuni che gli utenti commettono con i token ERC-20, oltre a come creare contratti ERC-20 che aiutino gli utenti a evitare tali errori o che conferiscano a un'autorità centrale un certo potere (ad esempio per congelare gli account).

Nota che, sebbene utilizzeremo il contratto del token ERC-20 di OpenZeppelin (si apre in una nuova scheda), questo articolo non lo spiega in modo molto dettagliato. Puoi trovare queste informazioni qui.

Se vuoi vedere il codice sorgente completo:

  1. Apri l'IDE Remix (si apre in una nuova scheda).
  2. Fai clic sull'icona per clonare da GitHub (clone github icon).
  3. Clona il repository GitHub https://github.com/qbzzt/20220815-erc20-safety-rails.
  4. Apri contracts > erc20-safety-rails.sol.

Creazione di un contratto ERC-20

Prima di poter aggiungere la funzionalità delle misure di sicurezza, abbiamo bisogno di un contratto ERC-20. In questo articolo utilizzeremo l'OpenZeppelin Contracts Wizard (si apre in una nuova scheda). Aprilo in un altro browser e segui queste istruzioni:

  1. Seleziona ERC20.

  2. Inserisci queste impostazioni:

    ParametroValore
    NomeSafetyRailsToken
    SimboloSAFE
    Premint1000
    FunzionalitàNessuna
    Controllo accessiOwnable
    AggiornabilitàNessuna
  3. Scorri verso l'alto e fai clic su Open in Remix (per Remix) o su Download per utilizzare un ambiente diverso. Darò per scontato che tu stia utilizzando Remix; se usi qualcos'altro, apporta semplicemente le modifiche appropriate.

  4. Ora abbiamo un contratto ERC-20 completamente funzionante. Puoi espandere .deps > npm per vedere il codice importato.

  5. Compila, distribuisci e gioca con il contratto per vedere che funziona come un contratto ERC-20. Se hai bisogno di imparare a usare Remix, usa questo tutorial (si apre in una nuova scheda).

Errori comuni

Gli errori

A volte gli utenti inviano token all'indirizzo sbagliato. Sebbene non possiamo leggere le loro menti per sapere cosa intendessero fare, ci sono due tipi di errori che si verificano spesso e sono facili da rilevare:

  1. Inviare i token all'indirizzo stesso del contratto. Ad esempio, il token OP di Optimism (si apre in una nuova scheda) è riuscito ad accumulare oltre 120.000 (si apre in una nuova scheda) token OP in meno di due mesi. Ciò rappresenta una quantità significativa di ricchezza che presumibilmente le persone hanno semplicemente perso.

  2. Inviare i token a un indirizzo vuoto, uno che non corrisponde a un account di proprietà esterna (EOA) o a uno smart contract. Sebbene non abbia statistiche su quanto spesso ciò accada, un singolo incidente potrebbe essere costato 20.000.000 di token (si apre in una nuova scheda).

Prevenire i trasferimenti

Il contratto ERC-20 di OpenZeppelin include un hook, _beforeTokenTransfer (si apre in una nuova scheda), che viene chiamato prima che un token venga trasferito. Per impostazione predefinita, questo hook non fa nulla, ma possiamo agganciarvi le nostre funzionalità, come dei controlli che eseguono un revert in caso di problemi.

Per utilizzare l'hook, aggiungi questa funzione dopo il costruttore:

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

Alcune parti di questa funzione potrebbero essere nuove se non hai molta familiarità con Solidity:

        internal virtual

La parola chiave virtual significa che, proprio come abbiamo ereditato la funzionalità da ERC20 e sovrascritto questa funzione, altri contratti possono ereditare da noi e sovrascrivere questa funzione.

        override(ERC20)

Dobbiamo specificare esplicitamente che stiamo sovrascrivendo (si apre in una nuova scheda) la definizione del token ERC20 di _beforeTokenTransfer. In generale, le definizioni esplicite sono molto migliori, dal punto di vista della sicurezza, rispetto a quelle implicite: non puoi dimenticare di aver fatto qualcosa se è proprio di fronte a te. Questo è anche il motivo per cui dobbiamo specificare quale _beforeTokenTransfer della superclasse stiamo sovrascrivendo.

        super._beforeTokenTransfer(from, to, amount);

Questa riga chiama la funzione _beforeTokenTransfer del contratto o dei contratti da cui abbiamo ereditato che la possiedono. In questo caso, si tratta solo di ERC20, Ownable non ha questo hook. Anche se attualmente ERC20._beforeTokenTransfer non fa nulla, lo chiamiamo nel caso in cui vengano aggiunte funzionalità in futuro (e decidessimo quindi di ridistribuire il contratto, perché i contratti non cambiano dopo la distribuzione).

Codificare i requisiti

Vogliamo aggiungere questi requisiti alla funzione:

  • L'indirizzo to non può essere uguale a address(this), l'indirizzo del contratto ERC-20 stesso.
  • L'indirizzo to non può essere vuoto, deve essere uno dei seguenti:
    • Un account di proprietà esterna (EOA). Non possiamo verificare direttamente se un indirizzo è un EOA, ma possiamo controllare il saldo in ETH di un indirizzo. Gli EOA hanno quasi sempre un saldo, anche se non vengono più utilizzati: è difficile svuotarli fino all'ultimo Wei.
    • Uno smart contract. Verificare se un indirizzo è uno smart contract è un po' più difficile. Esiste un codice operativo (opcode) che controlla la lunghezza del codice esterno, chiamato EXTCODESIZE (si apre in una nuova scheda), ma non è disponibile direttamente in Solidity. Dobbiamo usare Yul (si apre in una nuova scheda), che è l'assembly dell'EVM, per farlo. Ci sono altri valori che potremmo usare da Solidity (<address>.code e <address>.codehash (si apre in una nuova scheda)), ma costano di più.

Esaminiamo il nuovo codice riga per riga:

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

Questo è il primo requisito, verificare che to e this(address) non siano la stessa cosa.

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

Ecco come verifichiamo se un indirizzo è un contratto. Non possiamo ricevere l'output direttamente da Yul, quindi definiamo invece una variabile per contenere il risultato (isToContract in questo caso). Il modo in cui funziona Yul è che ogni codice operativo (opcode) è considerato una funzione. Quindi prima chiamiamo EXTCODESIZE (si apre in una nuova scheda) per ottenere la dimensione del contratto, e poi usiamo GT (si apre in una nuova scheda) per verificare che non sia zero (abbiamo a che fare con interi senza segno, quindi ovviamente non può essere negativo). Scriviamo quindi il risultato in isToContract.

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

E infine, abbiamo il controllo vero e proprio per gli indirizzi vuoti.

Accesso amministrativo

A volte è utile avere un amministratore che possa annullare gli errori. Per ridurre il potenziale di abuso, questo amministratore può essere un multisig (si apre in una nuova scheda) in modo che più persone debbano concordare su un'azione. In questo articolo avremo due funzionalità amministrative:

  1. Congelare e scongelare gli account. Questo può essere utile, ad esempio, quando un account potrebbe essere compromesso.

  2. Pulizia degli asset.

    A volte i truffatori inviano token fraudolenti al contratto del token reale per acquisire legittimità. Ad esempio, vedi qui (si apre in una nuova scheda). Il contratto ERC-20 legittimo è 0x4200....0042 (si apre in una nuova scheda). La truffa che finge di esserlo è 0x234....bbe (si apre in una nuova scheda).

    È anche possibile che le persone inviino per errore token ERC-20 legittimi al nostro contratto, il che è un altro motivo per voler avere un modo per tirarli fuori.

OpenZeppelin fornisce due meccanismi per abilitare l'accesso amministrativo:

Per motivi di semplicità, in questo articolo utilizziamo Ownable.

Congelare e scongelare i contratti

Congelare e scongelare i contratti richiede diverse modifiche:

  • Una mappatura (mapping) (si apre in una nuova scheda) dagli indirizzi ai booleani (si apre in una nuova scheda) per tenere traccia di quali indirizzi sono congelati. Tutti i valori sono inizialmente zero, che per i valori booleani viene interpretato come falso. Questo è ciò che vogliamo perché per impostazione predefinita gli account non sono congelati.

        mapping(address => bool) public frozenAccounts;
    
  • Eventi (si apre in una nuova scheda) per informare chiunque sia interessato quando un account viene congelato o scongelato. Tecnicamente parlando, gli eventi non sono richiesti per queste azioni, ma aiutano il codice offchain a poter ascoltare questi eventi e sapere cosa sta succedendo. È considerata buona educazione per uno smart contract emetterli quando accade qualcosa che potrebbe essere rilevante per qualcun altro.

    Gli eventi sono indicizzati, quindi sarà possibile cercare tutte le volte in cui un account è stato congelato o scongelato.

      // Quando gli account vengono congelati o scongelati
      event AccountFrozen(address indexed _addr);
      event AccountThawed(address indexed _addr);
    
  • Funzioni per congelare e scongelare gli account. Queste due funzioni sono quasi identiche, quindi esamineremo solo la funzione di congelamento.

        function freezeAccount(address addr)
          public
          onlyOwner
    

    Le funzioni contrassegnate con public (si apre in una nuova scheda) possono essere chiamate da altri smart contract o direttamente da una transazione.

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

    Se l'account è già congelato, esegui un revert. Altrimenti, congelalo ed emetti (emit) un evento.

  • Modifica _beforeTokenTransfer per impedire che il denaro venga spostato da un account congelato. Nota che il denaro può ancora essere trasferito nell'account congelato.

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

Pulizia degli asset

Per rilasciare i token ERC-20 detenuti da questo contratto dobbiamo chiamare una funzione sul contratto del token a cui appartengono, ovvero transfer (si apre in una nuova scheda) o approve (si apre in una nuova scheda). Non ha senso sprecare gas in questo caso per le autorizzazioni (allowance), tanto vale trasferire direttamente.

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

Questa è la sintassi per creare un oggetto per un contratto quando riceviamo l'indirizzo. Possiamo farlo perché abbiamo la definizione per i token ERC20 come parte del codice sorgente (vedi riga 4), e quel file include la definizione per IERC20 (si apre in una nuova scheda), l'interfaccia per un contratto ERC-20 di OpenZeppelin.

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

Questa è una funzione di pulizia, quindi presumibilmente non vogliamo lasciare alcun token. Invece di ottenere il saldo dall'utente manualmente, tanto vale automatizzare il processo.

Conclusione

Questa non è una soluzione perfetta: non esiste una soluzione perfetta per il problema "l'utente ha commesso un errore". Tuttavia, l'utilizzo di questo tipo di controlli può almeno prevenire alcuni errori. La capacità di congelare gli account, sebbene pericolosa, può essere utilizzata per limitare i danni di determinati hack negando all'hacker i fondi rubati.

Vedi qui per altri miei lavori (si apre in una nuova scheda).