ERC-20 com barreiras de proteção
Introdução
Uma das grandes vantagens do Ethereum é que não há uma autoridade central que possa modificar ou desfazer suas transações. Um dos grandes problemas do Ethereum é que não há uma autoridade central com o poder de desfazer erros de usuários ou transações ilícitas. Neste artigo, você aprenderá sobre alguns dos erros comuns que os usuários cometem com tokens ERC-20, bem como a criar contratos ERC-20 que ajudam os usuários a evitar esses erros, ou que dão a uma autoridade central algum poder (por exemplo, para congelar contas).
Observe que, embora usemos o contrato de token ERC-20 da OpenZeppelin (opens in a new tab), este artigo não o explica em grandes detalhes. Você pode encontrar essas informações aqui.
Se você quiser ver o código-fonte completo:
- Abra a IDE Remix (opens in a new tab).
- Clique no ícone de clonar do GitHub (
). - Clone o repositório do GitHub
https://github.com/qbzzt/20220815-erc20-safety-rails. - Abra contracts > erc20-safety-rails.sol.
Criando um contrato ERC-20
Antes de podermos adicionar a funcionalidade de barreira de proteção, precisamos de um contrato ERC-20. Neste artigo, usaremos o Assistente de Contratos da OpenZeppelin (opens in a new tab). Abra-o em outro navegador e siga estas instruções:
-
Selecione ERC20.
-
Insira estas configurações:
Parâmetro Valor Nome SafetyRailsToken Símbolo SAFE Pré-cunhagem 1000 Recursos Nenhum Controle de Acesso Ownable Capacidade de atualização Nenhum -
Role para cima e clique em Open in Remix (para o Remix) ou Download para usar um ambiente diferente. Vou presumir que você está usando o Remix; se usar outra coisa, basta fazer as alterações apropriadas.
-
Agora temos um contrato ERC-20 totalmente funcional. Você pode expandir
.deps>npmpara ver o código importado. -
Compile, implante e brinque com o contrato para ver que ele funciona como um contrato ERC-20. Se você precisar aprender a 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 possamos ler suas mentes para saber o que eles queriam fazer, existem dois tipos de erros que acontecem muito e são fáceis de detectar:
-
Enviar os tokens para o próprio endereço do contrato. Por exemplo, o token OP da Optimism (opens in a new tab) conseguiu acumular mais de 120.000 (opens in a new tab) tokens OP em menos de dois meses. Isso representa uma quantidade significativa de riqueza que presumivelmente as pessoas simplesmente perderam.
-
Enviar os tokens para um endereço vazio, um que não corresponde a uma conta de propriedade externa (EOA) ou a um contrato inteligente. Embora eu não tenha estatísticas sobre a frequência com que isso acontece, um incidente poderia ter custado 20.000.000 de tokens (opens in a new tab).
Prevenindo transferências
O contrato ERC-20 da OpenZeppelin inclui um gancho (hook), _beforeTokenTransfer (opens in a new tab), que é chamado antes que um token seja transferido. Por padrão, esse gancho não faz nada, mas podemos pendurar nossa própria funcionalidade nele, como verificações que revertem se houver um problema.
Para usar o gancho, adicione esta função após o construtor:
function _beforeTokenTransfer(address from, address to, uint256 amount)
internal virtual
override(ERC20)
{
super._beforeTokenTransfer(from, to, amount);
}
Algumas partes desta função podem ser novas se você não estiver muito familiarizado com a Solidity:
internal virtual
A palavra-chave virtual significa que, assim como herdamos a funcionalidade de ERC20 e substituímos esta função, outros contratos podem herdar de nós e substituir esta função.
override(ERC20)
Temos que especificar explicitamente que estamos substituindo (overriding) (opens in a new tab) a definição do 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 esquecer que fez algo se estiver bem na sua frente. Essa também é a razão pela qual precisamos especificar qual _beforeTokenTransfer da superclasse estamos substituindo.
super._beforeTokenTransfer(from, to, amount);
Esta linha chama a função _beforeTokenTransfer do contrato ou contratos dos quais herdamos que a possuem. Neste caso, é apenas ERC20, Ownable não tem esse gancho. Mesmo que atualmente ERC20._beforeTokenTransfer não faça nada, nós a chamamos caso alguma funcionalidade seja adicionada no futuro (e então decidamos reimplantar o contrato, porque os contratos não mudam após a implantação).
Codificando os requisitos
Queremos adicionar estes requisitos à função:
- O endereço
tonão pode ser igual aaddress(this), o endereço do próprio contrato ERC-20. - O endereço
tonão pode estar vazio, ele tem que ser:- Uma conta de propriedade externa (EOA). Não podemos verificar se um endereço é uma EOA diretamente, mas podemos verificar o saldo de ETH de um endereço. As EOAs quase sempre têm um saldo, mesmo que não sejam mais usadas - é difícil limpá-las até o último Wei.
- Um contrato inteligente. Testar se um endereço é um contrato inteligente é um pouco mais difícil. Existe um código de operação que verifica o comprimento do código externo, chamado
EXTCODESIZE(opens in a new tab), mas não está disponível diretamente na Solidity. Temos que usar Yul (opens in a new tab), que é o assembly da EVM, para isso. Existem outros valores que poderíamos usar da Solidity (<address>.codee<address>.codehash(opens in a new tab)), mas eles custam mais gás.
Vamos analisar o novo código linha por linha:
require(to != address(this), "Can't send tokens to the contract address");
Este é o primeiro requisito, verificar se to e this(address) não são a mesma coisa.
bool isToContract;
assembly {
isToContract := gt(extcodesize(to), 0)
}
É assim que verificamos se um endereço é um contrato. Não podemos receber a saída diretamente de Yul, então, em vez disso, definimos uma variável para armazenar o resultado (isToContract neste caso). A maneira como Yul funciona é que cada código de operação é considerado 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, não pode ser negativo). Em seguida, escrevemos o resultado em isToContract.
require(to.balance != 0 || isToContract, "Can't send tokens to an empty address");
E, finalmente, temos a verificação real para endereços vazios.
Acesso administrativo
Às vezes, é útil ter um administrador que possa desfazer erros. Para reduzir o potencial de abuso, esse administrador pode ser uma 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. Isso pode ser útil, por exemplo, quando uma conta pode estar 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). O golpe que finge ser ele é 0x234....bbe (opens in a new tab).
Também é possível que as pessoas enviem tokens ERC-20 legítimos para o nosso contrato por engano, o que é outro motivo para querer ter uma maneira de retirá-los.
A OpenZeppelin fornece dois mecanismos para habilitar o acesso administrativo:
- Os contratos
Ownable(opens in a new tab) têm um único proprietário. As funções que têm o modificador (opens in a new tab)onlyOwnersó podem ser chamadas por esse 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 tipicamente idênticos. - Os contratos
AccessControl(opens in a new tab) têm controle de acesso baseado em função (RBAC) (opens in a new tab).
Por uma questão de simplicidade, neste artigo usamos Ownable.
Congelando e descongelando contratos
Congelar e descongelar contratos requer várias alterações:
-
Um mapeamento (mapping) (opens in a new tab) de endereços para booleanos (opens in a new tab) para acompanhar quais endereços estão congelados. Todos os valores são inicialmente zero, o que para valores booleanos é interpretado como falso. É isso que queremos porque, por padrão, as contas não estão congeladas.
mapping(address => bool) public frozenAccounts; -
Eventos (opens in a new tab) para informar a qualquer interessado quando uma conta é congelada ou descongelada. Tecnicamente falando, os eventos não são necessários para essas ações, mas ajudam o código offchain a ser capaz de ouvir esses eventos e saber o que está acontecendo. É considerado de bom tom que um contrato inteligente os emita quando algo que possa ser relevante para outra pessoa acontece.
Os eventos são indexados, portanto, será possível pesquisar todas as vezes que uma conta foi congelada ou descongelada.
// Quando as contas são congeladas ou descongeladas event AccountFrozen(address indexed _addr); event AccountThawed(address indexed _addr); -
Funções para congelar e descongelar contas. Essas duas funções são quase idênticas, então abordaremos apenas a função de congelamento.
function freezeAccount(address addr) public onlyOwnerAs funções marcadas como
public(opens in a new tab) podem ser chamadas de outros contratos inteligentes ou diretamente por uma transação.{ require(!frozenAccounts[addr], "Account already frozen"); frozenAccounts[addr] = true; emit AccountFrozen(addr); } // freezeAccountSe a conta já estiver congelada, reverta. Caso contrário, congele-a e emita (
emit) um evento. -
Altere
_beforeTokenTransferpara evitar que o dinheiro seja movido de uma conta congelada. Observe que o dinheiro ainda pode ser transferido para a conta congelada.require(!frozenAccounts[from], "The account is frozen");
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 pertencem, seja transfer (opens in a new tab) ou approve (opens in a new tab). Não faz sentido desperdiçar gás neste caso com permissões (allowances), podemos muito bem transferir diretamente.
function cleanupERC20(
address erc20,
address dest
)
public
onlyOwner
{
IERC20 token = IERC20(erc20);
Esta é a sintaxe para criar um objeto para um contrato quando recebemos o endereço. Podemos fazer isso porque temos a definição para 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 ERC-20 da OpenZeppelin.
uint balance = token.balanceOf(address(this));
token.transfer(dest, balance);
}
Esta é uma função de limpeza, então presumivelmente não queremos deixar nenhum token. Em vez de obter o saldo do usuário manualmente, podemos muito bem automatizar o processo.
Conclusão
Esta não é uma solução perfeita - não há solução perfeita para o problema "o usuário cometeu um erro". No entanto, o uso desses tipos de verificações pode pelo menos evitar alguns erros. A capacidade de congelar contas, embora perigosa, pode ser usada para limitar os danos de certos ataques, negando ao hacker os fundos roubados.