ERC-20 com Trilhos de Segurança
Introdução
Uma das melhores coisas sobre o Ethereum é que não há autoridade central que possa modificar ou desfazer transações. Um dos maiores problemas do Ethereum é que não há autoridade central com o poder de desfazer erros de usuário ou transações ilícitas. Neste artigo, você aprenderá sobre alguns dos erros comuns que usuários cometem com tokens ERC-20, assim como criar contratos ERC-20 que ajudam usuários a evitar esses erros, ou que dão a uma autoridade central algum poder (por exemplo, congelar contas).
Observe que, apesar de usarmos o contrato de token ERC-20 da OpenZeppelin(opens in a new tab), este artigo não o explica em maiores detalhes. Você pode encontrar esta informação aqui.
Se você quiser ver o código-fonte completo:
- Abra o Remix IDE(opens in a new tab).
- Clique o ícone de clonar o github ().
- Clone o repositório github
https://github.com/qbzzt/20220815-erc20-safety-rails
. - Abra contracts > erc20-safety-rails.sol.
Criando um contrato ERC-20
Antes que nós possamos adicionar funcionalidade de trilhos de segurança, nós precisamos de um contrato ERC-20. Neste artigo, usaremos o o Assistente de contratos da OpenZeppelin(opens in a new tab). Abra-o em outro navegador e siga estas instruções:
Selecione ERC20.
Entre estas configurações:
Parâmetro Valor Nome SafetyRailsToken Símbolo SAFE Pré-cunhagem 1.000 Recursos Nenhum Controle de acesso Proprietário Capacidade de atualização Nenhum Suba e clique Open in Remix (para o Remix) ou Download para usar um ambiente diferente. Vou presumir que você está usando o Remix. Se você estiver usando algo diferente, faça as mudanças apropriadas.
Agora, temos um contrato ERC-20 totalmente funcional. Você pode expandir
.deps
enpm
para ver o código importado.Compile, implante e brinque com o contrato para ver se ele funciona como um contrato ERC-20. Se você precisar aprender como usar o Remix, use este tutorial(opens in a new tab).
Erros comuns
Os erros
Às vezes, os usuários enviam tokens para o endereço errado. Embora não consigamos ler a mente dos usuários para saber o que querem fazer, há dois tipos de erros que ocorrem muitas vezes e são fáceis de detectar:
Enviar os tokens para o próprio endereço do contrato. Por exemplo, token Optimism's OP(opens in a new tab) acabou acumulando mais de 120.000(opens in a new tab) tokens OP em menos de dois meses. Isso representa uma quantia de dinheiro significativa, que presumimos que as pessoas tenham simplesmente perdido.
Enviar os tokens para um endereço vazio, um que não corresponde a uma conta de propriedade externa ou um contrato inteligente. Enquanto eu não tenho estatísticas de quão frequente isso acontece, um incidente poderia ter custado 20.000.000 de tokens(opens in a new tab).
Evitando transferências
O contrato OpenZeppelin ERC-20 inclui um hook, _beforeTokenTransfer
(opens in a new tab), que é chamado antes de um token ser transferido. Por padrão, esse hook não faz nada, mas podemos pendurar nossas próprias funcionalidades, como verificações que são anuladas se houver um problema.
Para usar o hook, adicione esta função depois do construtor:
1 function _beforeTokenTransfer(address from, address to, uint256 amount)2 internal virtual3 override(ERC20)4 {5 super._beforeTokenTransfer(from, to, amount);6 }Copiar
Algumas partes desta função podem ser novas se você não estiver muito familiarizado com o Solidity:
1 internal virtualCopiar
A palavra-chave virtual
significa que conforme herdamos funcionalidades do ERC20
e substituímos essa função, outros contratos podem herdar de nós e substituir essa função.
1 override(ERC20)Copiar
Temos que especificar explicitamente que estamos substituindo(opens in a new tab) a definição de token ERC20 de _beforeTokenTransfer
. Em geral, definições explícitas são muito melhores, do ponto de vista da segurança, do que as implícitas — você não pode se esquecer de que fez algo se isso estive bem na sua frente. Esta também é a razão que nós precisamos para especificar que superclasses _beforeTokenTransfer
nós estamos sobrepondo.
1 super._beforeTokenTransfer(from, to, amount);Copiar
Esta linha chama a função _beforeTokenTransfer
do contrato ou contratos que herdamos e que a possui. Neste caso, isto é somente ERC20
, Ownable
não tem esse hook. Mesmo que, atualmente, o ERC20._beforeTokenTransfer
não faça nada, nós o chamamos caso a funcionalidade seja adicionada no futuro (e nós então decidimos reimplantar o contrato, porque contratos não mudam depois da implantação).
Codificando os requisitos
Nós queremos adicionar estes requisitos para a função:
- O endereço
to
não pode ser igual aaddress(this)
, o endereço do contrato ERC-20 propriamente dito. - O endereço
to
não pode ser vazio, ele tem de ser:- Uma conta de propriedade externa (EOA). Nós não podemos checar se um endereço é um EOA diretamente, mas nós podemos checar o saldo em ETH de um endereço. EOAs quase sempre têm um saldo, mesmo que não estejam mais sendo usados — é difícil esvaziá-los até o último wei.
- Um contrato inteligente. Testar se um endereço é um contrato inteligente é um pouco mais difícil. Há um opcode que checa o tamanho do código externo, chamado
EXTCODESIZE
(opens in a new tab), mas ele não é disponível diretamente em Solidity. Para isso, temos que usar Yul(opens in a new tab), que é um assembly da EVM. Há outros valores do Solidity que poderíamos usar (<address>.code
e<address>.codehash
(opens in a new tab)), mas eles são mais caros.
Vamos passar sobre o código novo, linha a linha:
1 require(to != address(this), "Can't send tokens to the contract address");Copiar
Este é o primeiro requisito, verificar se to
e this(address)
não são a mesma coisa.
1 bool isToContract;2 assembly {3 isToContract := gt(extcodesize(to), 0)4 }Copiar
É assim que verificamos se um endereço é um contrato. Não podemos receber saídas diretamente do Yul, então, em vez disso, definimos uma variável para manter o resultado (isToContract
neste caso). A maneira como o Yul trabalha é considerando cada opcode como uma função. Então, primeiro chamamos EXTCODESIZE
(opens in a new tab) para obter o tamanho do contrato e, em seguida, usamos GT
(opens in a new tab) para verificar se não é zero (estamos lidando com inteiros sem sinal, então claro que ele não pode ser negativo). Então, escrevemos o resultado em isToContract
.
1 require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");Copiar
Por fim, temos a verificação real de endereços vazios.
Acesso administrativo
Algumas vezes é útil ter um administrador que pode desfazer erros. Para reduzir o potencial de abuso, esse administrador pode ser um multisig(opens in a new tab) para que várias pessoas tenham que concordar com uma ação. Neste artigo, teremos dois recursos administrativos:
Congelar e descongelar contas. Isto pode ser útil, por exemplo, quando uma conta for comprometida.
Limpeza de ativos.
Às vezes, fraudadores enviam tokens fraudulentos para o contrato do token real para ganhar legitimidade. Por exemplo, veja aqui(opens in a new tab). O contrato ERC-20 legítimo é 0x4200....0042(opens in a new tab). A fraude que finge ser o contrato é 0x234....bbe(opens in a new tab).
Também é possível que pessoas enviem tokens ERC-20 legítimos para nosso contrato por erro, que é outra razão para querer ter uma maneira de tirá-los de lá.
OpenZeppelin fornece dois mecanismos para habilitar acesso administrativo:
Ownable
(opens in a new tab) contratos tem um único priprietário. Funções que tem o modifier(opens in a new tab)onlyOwner
só podem ser chamadas por este proprietário. Os proprietários podem transferir a propriedade para outra pessoa ou renunciar a ela completamente. Os direitos de todas as outras contas são geralmente idênticas.- Os contratos
AccessControl
(opens in a new tab) têm controle de acesso baseado em função (RBAC)(opens in a new tab).
Por simplicidade, neste artigo usamos Ownable
.
Congelando e descongelando contratos
Congelar e descongelar contratos requer várias mudanças:
Um mapeamento(opens in a new tab) de endereços em booleanos(opens in a new tab) para manter o controle de quais endereços estão congelados. Todos os valores são inicialmente zero, o que, para valores booleanos, é interpretado como falso. Isto é o que queremos porque, por padrão, as contas não são congeladas.
1 mapping(address => bool) public frozenAccounts;CopiarEventos(opens in a new tab) para informar qualquer pessoa interessada, quando uma conta é congelada ou descongelada. Tecnicamente falando, os eventos não são necessários para essas ações, mas ajuda o código fora da cadeia a ser capaz de ouvir esses eventos e saber o que está acontecendo. É considerado uma boa conduta para um contrato inteligente emiti-los quando acontece algo que pode ser relevante para outra pessoa.
Os eventos são indexados, então, será possível pesquisar todas as vezes que uma conta foi congelada ou descongelada.
1 // When accounts are frozen or unfrozen2 event AccountFrozen(address indexed _addr);3 event AccountThawed(address indexed _addr);CopiarFunções para congelar e descongelar contas. Essas duas funções são praticamente idênticas, por isso, analisaremos apenas a função de congelamento.
1 function freezeAccount(address addr)2 public3 onlyOwnerCopiarAs funções marcadas como
public
(opens in a new tab) podem ser chamadas a partir de outros contratos inteligentes ou diretamente por uma transação.1 {2 require(!frozenAccounts[addr], "Account already frozen");3 frozenAccounts[addr] = true;4 emit AccountFrozen(addr);5 } // freezeAccountCopiarSe a conta já estiver congelada, reverta-a. Caso contrário, congele-a e envie um evento
emit
.Mude o
_beforeTokenTransfer
para evitar que o dinheiro seja movido de uma conta congelada. Note que o dinheiro ainda pode ser transferido para a conta congelada.1 require(!frozenAccounts[from], "The account is frozen");Copiar
Limpeza de ativos
Para liberar os tokens ERC-20 mantidos por este contrato, precisamos chamar uma função no contrato do token ao qual eles fazem parte, transfer
(opens in a new tab) ou approve
(opens in a new tab). Nesse caso, não faz sentido desperdiçar gás em provisões. Vale mais a pena transferir diretamente.
1 function cleanupERC20(2 address erc20,3 address dest4 )5 public6 onlyOwner7 {8 IERC20 token = IERC20(erc20);Copiar
Essa é a sintaxe para criar um objeto para um contrato quando recebemos o endereço. Podemos fazer isso porque temos a definição de tokens ERC20 como parte do código-fonte (veja a linha 4) e esse arquivo inclui a definição para IERC20(opens in a new tab), a interface para um contrato OpenZeppelin ERC-20.
1 uint balance = token.balanceOf(address(this));2 token.transfer(dest, balance);3 }Copiar
Esta é uma função de limpeza, portanto, provavelmente não queremos deixar nenhum token. Em vez de obter o saldo do usuário manualmente, podemos também automatizar o processo.
Conclusão
Esta não é uma solução perfeita — não há solução perfeita para o problema do “usuário que cometeu um erro”. No entanto, usar esses tipos de verificações pode, pelo menos, evitar alguns erros. A capacidade de congelar contas, embora seja perigosa, pode ser usada para limitar os danos de certos ataques ao negar ao hacker os fundos roubados.
Última edição: @omahs(opens in a new tab), 17 de fevereiro de 2024