ERC-20: construindo um contrato de tokens fungíveis

Lucas Vieira
Block3 Research
Published in
12 min readMay 24, 2022

No nosso último post detalhamos quais são e pra que servem os mais variados tipos de protocolos ERC. Os protocolos nos fornecem padrões para criar contratos que conversem entre si, auxiliando a comunidade no desenvolvimento e uso de serviços em blockchain.

Nesse artigo vamos botar a mão na massa e construir um contrato que segue o protocolo ERC-20 de tokens fungíveis, usando a linguagem de programação Solidity, a ferramenta de desenvolvimento Hardhat, algumas bibliotecas do OpenZeppelin, Chai para os testes além de usar a API do CoinMarketCap para trazer uma estimativa de custos de gás com valores reais atualizados.

1. Configuração

O primeiro passo é instalar (caso já não estejam instalados) o Node.js e npm no seu computador. Em seguida vamos abrir uma janela de terminal, criar o diretório, iniciar um novo projeto npm nele e instalar o pacote do hardhat.

mkdir erc20-fungible-token-contract
cd erc20-fungible-token-contract/
npm init --yes
npm install --save-dev hardhat

Em seguida é hora de criar um novo projeto hardhat com o comando

npx hardhat

Serão exibidas uma série de opções e, nesse momento, vamos escolher a de criar um projeto exemplo básico

Em seguida confirmamos a pasta onde o projeto vai ser inserido e podemos escolher "sim" nas opções de adicionar um arquivo de .gitignore e de instalar as dependências do projeto.

Abrindo o diretório com um editor de código podemos ver que uma série de arquivos foram criados como um contrato .sol, um script de deploy e um script de testes.

Vamos primeiro renomear (ou deletar e criar um novo) o arquivo de contrato para Token.sol e vamos incluir o seguinte conteúdo nele:

Para um contrato ser compatível com o protocolo ERC-20 precisamos no mínimo ter as funções transfer, balanceOf, totalSupply, transferFrom, approve e allowance. Além dos eventos Transfer e Approval. Nesse ponto, poderíamos apenas importar e usar a biblioteca ERC20 do OpenZeppelin. Mas como o intuito desse post é educativo, vamos arregaçar as mangas e escrever o código nós mesmos! Vamos começar definindo as variáveis necessárias, os eventos e o construtor do contrato.

Primeiro temos a variável _totalSupply para guardar a quantidade de tokens disponíveis e os mappings _balances e _allowances para guardar a quantidade tokens possuídas por cada endereço e quantos tokens pertencentes à um endereço um terceiro endereço pode gerenciar. Além disso, temos os eventos Transfer e Approval que devem ser emitidos sempre que houver uma transferência de tokens ou quando um proprietário de tokens aprovar que um endereço terceiro gerencie parte de seus tokens. Por último, temos o construtor do nosso contrato. O bloco de código dentro do constructor é executado uma única vez no momento da criação do contrato. Nesse caso recebemos um parâmetro com a quantidade de tokens inicial, alteramos a variável que armazena essa informação, transferimos essa quantidade para o endereço que executou a criação do contrato (no caso o msg.sender) e, por final, emitimos um evento para mostrar que foi feita uma transferência dessa quantidade de tokens para este endereço. Em seguida vamos criar as funções de leitura, funções que não alteram o estado dos dados presentes na blockchain, apenas retornam essa informação.

Definimos então as funções totalSupply, balanceOf e allowance que retornam a quantidade de tokens disponíveis, a quantidade de tokens que um endereço possui e a quantidade de tokens de um endereço que um terceiro endereço está permitido a gerenciar respectivamente. O próximo passo vai ser definir as funções internas (apenas chamadas pelo próprio contrato) que executam as operações de transferência e aprovação de tokens. Em seguida vamos definir as funções externas (que podem ser chamadas por usuários) para executar as respectivas funcionalidades.

A função _transfer permite a transferência de uma quantidade amount de tokens do enedereço sender para o endereço recipient, além de emitir um evento Transfer sinalizando que a operação foi realizada. A função approve permite que um endereço owner sinalize que um outro endereço spender possa gerenciar uma quantidade amount de seus tokens. E por fim, temos as funções externas transfer, approve e transferFrom que podem ser executadas pelos usuários e usam as funções internas com os parâmetros corretos.

Nesse ponto já temos um contrato com o mínimo para ser compatível com o protocolo ERC-20!

2. Customização

Agora chegou o ponto de customizarmos esse contrato e incluirmos outras funcionalidades importantes para o nosso projeto. Vamos começar incluindo a lógica para termos a extensão opcional de metadados para o protocolo ERC-20 que nos permite atrelar um nome, um símbolo e a quantidade de casas decimais ao nosso token.

O que fizemos aqui foi incluir as variáveis _decimals, _symbol e _name além de setar sua definição no momento de criação do contrato. Incluímos também as funções de leitura decimals, symbol e name que retornam a informação contida nessas variáveis. Vamos incluir agora funções para que seja possível emitir novos tokens com a função mint e destruir tokens com a função burn. Essas funções são sensíveis e precisamos incluir uma lógica para que apenas o dono do contrato possa executá-las. E para isso, dessa vez, vamos usar a biblioteca de controle de acesso Ownable do OpenZeppelin. Essa função nos permite, além de outras funcionalidades, incluir o modificador onlyOwner em qualquer função do contrato. As funções que tiverem esse modificador só poderão ser executadas pelo dono do contrato. Vamos então instalar a biblioteca de contratos do OpenZeppelin com o seguinte comando.

npm install @openzeppelin/contracts --save-dev

Para usar a biblioteca no arquivo precisamos fazer o import dela no arquivo e a incluir a herança dela na definição do contrato da seguinte maneira.

import "@openzeppelin/contracts/access/Ownable.sol";contract Token is Ownable {}

Nas funções de mint e burn temos que ficar atentos que precisamos aumentar/diminuir a quantidade de tokens a ser emitida/destruída tanto do _totalSupply quanto do saldo do endereço de referência além de emitir um evento Transfer.

Note que as funções públicas de mint e burn possuem o modificador onlyOwner, dessa forma apenas o criador do contrato (o msg.sender que executou o deploy) pode executá-las. A biblioteca Ownable do OpenZeppelin também nos permite alterar o dono do contrato com a função transferOwnership.

3. Realização de Testes

Agora com o nosso contrato de Token escrito, vamos aos testes! Vamos usar a biblioteca Chai para nos auxiliar nesse processo. Primeiro vamos renomear o arquivo test/sample-test.js e começar a reescrevê-lo.

Nós começamos o teste garantindo que o contrato tenha o formato correto uma vez que o deploy for feito. Primeiro usamos a função getSigners da biblioteca ethers que nos retorna um array com 20 contas ethereum. Sempre que não mencionamos qual das contas estamos utilizando quando interagimos com a biblioteca, como nas linhas seguintes em que pegamos o contractFactory e fazemos o deploy do contrato, a biblioteca por padrão entende que a conta na posição 0 do array é quem está fazendo a chamada. Então como a conta 0 chamou o deploy, ela será considerada o owner do nosso contrato nos testes.

Na função de deploy passamos os seguintes argumentos (5000000, "NiceToken", "NTKN", 18) que são respectivamente o tokenTotalSupply, tokenName, tokenSymbol e tokenDecimals que são os parâmetros recebidos no construtor do contrato. A seguir, chamamos as funções de leitura e checamos com a função de asserção assert do chai para garantir que elas foram definidas com os valores corretos! Além disso, checamos se o saldo do dono do contrato recebeu todos os tokens criados. Para rodar os testes usamos o seguinte comando:

npx hardhat test

Vamos agora incluir testes para checar se a funcionalidade de transferência de tokens está funcionando corretamente. Para deixar nosso diretório de testes organizados vamos criar um novo arquivo para os testes dessa funcionalidade. Criamos então o test/tokenTransfers.js.

Incluímos então 3 novos testes. No primeiro checamos se o dono do contrato, inicialmente com todos os 5000000 tokens consegue trasnferir 10000 tokens para uma segunda conta, do Lucas. Em seguida testamos se o contrato nos retorna um erro ao Lucas tentar transferir 15000 tokens (mais que seu saldo) para João. E por último, se Lucas consegue corretamente transferir 5000 tokens (menos que seu saldo) para Carol. Sempre confirmando se os saldos são atualizados corretamente. Rodamos o comando de testes novamente para certificar se tudo funcionou como deveria.

Vamos notar alguns pontos importantes nesse último arquivo de testes. Quando quisermos executar alguma função do contrato com uma conta diferente da conta 0, a conta do dono, podemos usar a função connect.

await token.connect(lucas).transfer(await carol.getAddress(), 5000);

E também usamos a função expect da biblioteca chai no seguinte formato para checarmos se um erro foi corretamente disparado.

await expect(token.connect(lucas).transfer(await joao.getAddress(), 15000)).to.be.revertedWith("Token: cannot transfer more than account owns");

Checando basicamente se a mensagem de erro retornada é igual a mensagem de erro definida no require da função de transfer no contrato.

require(_balances[sender] >= amount, "Token: cannot transfer more than account owns");

Outro ponto importante é checar se o teste falha ao mudarmos algum dos parâmetros das funções de assert vamos alterar o último deles da seguinte maneira e rodar os testes novamente.

assert.equal(carolBalance.toNumber(), 10000, "Carol balance was not updated correctly");

Podemos checar que o teste falha como o esperado.

O próximo conjunto de testes vai ser para checarmos a funcionalidade de permitir que um endereço possa fazer a gestão dos tokens de outro. Então vamos começar criando mais um arquivo, o test/tokenAllowances.js.

Nesse arquivo realizamos mais 3 testes, o primeiro aprovar um endereço para gerenciar uma quantidade dos seus tokens. O segundo para checar que um erro é retornado quando esse endereço tentar transferir uma quantidade maior que a dos tokens liberados. E o último teste para checar que a transferência de um valor menor ou igual do total liberado é possível de ser transferida corretamente pelo endereço gestor. Agora vamos escrever os últimos dois arquivos de teste para testar as funcionalidades de mint e burn de tokens. Vamos então criar os arquivos test/tokenMint.js e test/tokenBurn.js.

Esses arquivos possuem 2 testes cada. Um para testar que o mint/burn foi executado corretamente pelo dono do contrato e o total de tokens disponíveis e o saldo de quem recebeu/perdeu os tokens foi corretamente atualizado. E o segundo teste para checar que outras contas, sem ser o dono do contrato, não podem executar as funções. Agora temos testes para todas funcionalidades do contrato e podemos checar que eles passam como o esperado!

Um ponto extremamente importante nos testes, principalmente para projetos que vão rodar em produção, é a inclusão do gas reporter para ter uma estimativa dos custos reais das funções do contrato. Para isso vamos instalar a biblioteca hardhat-gas-reporter através do seguinte comando.

npm install hardhat-gas-reporter --save-dev

O próximo passo vai ser incluir o import da biblioteca no arquivo de configuração do hardhat hardat.config.js que está no diretório principal do projeto.

require("hardhat-gas-reporter");

Agora podemos notar que ao rodar o comando de teste no terminal, recebemos um sumário com os gastos médios de gás em cada uma das funções executadas nos testes.

Para melhorar ainda mais, podemos usar a API do CoinMarketCap para retornar os valores e calcular de forma automática os custos de gás em qualquer moeda, com seu valor atual de mercado. Para isso, precisamos criar uma conta nesse site. Após fazer a validação do email ganhamos acesso ao site e a nossa API Key. Vamos precisar copiar essa chave.

Com essa chave em mãos, voltamos ao arquivo de configuração do hardhat e vamos incluir o seguinte objeto no export do arquivo.

Onde o COINMARKETCAP_API_KEY é a chave copiada do site. Além disso, escolhemos também a moeda em que o sumário vai ser feito. O arquivo final de configuração deverá ficar similar com o seguinte.

Agora ao rodarmos o comando de testes no terminal vamos ter o seguinte sumário.

Com os custos médios na moeda escolhida! Se usarmos "BRL" no parâmetro currency temos os custos médios em reais.

4. Deploy

Agora vamos escrever nosso script para fazer o deploy do nosso contrato. O deploy é feito para de fato subirmos nosso contrato para alguma rede como a Mainnet, Rinkeby e Goerli da Ethereum ou até mesmo para a Mainnet ou Mumbai da Polygon.

Dentro do diretório scripts/ temos um arquivo sample-script.js que foi gerado automaticamente quando iniciamos o projeto com o hardhat. Vamos deletar esse arquivo e criar um novo com o nome deploy.js. Incialmente, esse arquivo deverá ter o seguinte formato.

Primeiro fazemos o import da função de task da config do hardhat que nos permite setar um ação que pode ser executada com o seguinte comando

npx hardhat deploy

O parâmetro deploy desse comando é o primeiro argumento definido na função task. Então se setarmos "amoeba" lá, deveremos usar

npx hardhat amoeba

para executá-lo. A seguir, vamos incluir a lógica necessária para o deploy do contrato.

O primeiro ponto a se notar aqui é que retornamos uma váriavel deployer que é usada nos passos seguintes de deploy. Essa variável é definida no arquivo de configuração do hardhat, como veremos adiante. A função getContractFactory tem como primeiro parâmetro o nome do contrato definido no arquivo Token.sol na classe contract.

Um ponto importante é que antes de fazermos o deploy, precisamos lembrar de compilar nosso contrato. A função mencionada acima vai checar esse nome de contrato no diretório artifacts/ que é gerado após a compilação do contrato. Podemos executar a compilação com seguinte comando:

npx hardhat compile

Após isso, executamos o deploy com os valores que queremos passar para o construtor do contrato e colocamos um log para identificar o endereço do contrato deployado (lembre-se de guardar esse valor!).

Se executarmos o comando de deploy agora, poderemos ver que ele não irá funcionar. Isso porque ainda precisamos fazer algumas modificações no arquivo de configuração hardhat.config.js. Primeiro deletamos a task padrão de accounts que vem junto com a criação do projeto sample. Depois incluímos o import do arquivo de script de deploy criado.

require("./scripts/deploy.js");

Além de adicionar o objeto networks no export com as informações da rede em que queremos fazer o deploy.

Esse objeto pode conter múltiplos objetos internamente caso quisermos fazer o deploy para diferentes redes. No caso, vamos realizar na rede Rinkeby. Podemos ver mais detalhes sobre o formato do objeto na documentação do hardhat. A váriável RINKEBY_URL deve ser preenchida com o valor do provedor de nó da blockchain usada. Os mais famosos são o Alchemy e Infura. No caso deste tutorial, iremos usar o Alchemy. Basta criarmos uma conta por lá, selecionarmos a Ethereum chain e criar um novo app na rede Rinkeby.

Após a criação do App, basta copiar chave de API no formato HTTP e inseri-la no lugar do parâmetro url do arquivo de configuração. Note que temos um outro parâmetro accounts que é um array com as contas que são retornadas pela função getSigners do script de deploy. Essas variáveis devem conter as chaves privadas da carteira usada para fazer o deploy do contrato. Note que para o deploy ser feito corretamente, vai ser preciso ter algum ETH na sua carteira. Recomendo o uso do Metamask. Copie sua chave privada e substitua pela variável DEV_PRIVATE_KEY. No final, nosso arquivo de configuração deverá ter o seguinte formato:

Como mencionado anteriomente, para fazer o deploy vamos precisar de ETH da rede Rinkeby (Fake Ether) na nossa carteira. Podemos conseguir acessando alguns dos Faucets disponíveis (1, 2, 3). Agora basta compilarmos o nosso contrato e em seguida rodar o comando de deploy passando a rede definida como parâmetro.

npx hardhat deploy --network rinkeby

E podemos verificar que nosso contrato subiu corretamente para a rede em questão no seguinte endereço:

Podemos confirmar isso checando a Etherscan da rede Rinkeby! Basta usarmos o buscador com o endereço em questão. No caso desse contrato, nesse link.

Agora todas as transações que forem executadas nesse contrato poderão ser acompanhadas nessa página.

Aqui terminamos nosso tutorial. O código final pode ser encontrado aqui no meu Github.

Obrigado por acompanhar até aqui e espero que tenha sido útil para você que está lendo :)

--

--

Lucas Vieira
Block3 Research

Hi! I'm a brazilian software engineer who love to learn and build new things. My goal here is to share I little about that on the way :) https://bushi.solutions