Recentemente ensinei como criar uma nova criptomoeda, ou melhor, um novo token, no padrão da rede Ethereum (ERC-20), o mais usado mundialmente. Naquele momento, usamos a ferramenta Remix e fizemos todo o processo até o deploy e a configuração do token na nossa carteira MetaMask. Recomendo fortemente que faça esse outro tutorial primeiro antes desse aqui, pois hoje vamos aprender novamente a criar novos tokens ERC-20, mas usando ferramentas mais profissionais como o HardHat e OpenZeppelin.
Outro ponto importante é que este não deve ser o seu primeiro tutorial envolvendo a linguagem Solidity ou mesmo o toolkit HardHat. Se esse for o seu caso, recomendo começar por este outro aqui.
#1 – Criando o Projeto
O primeiro passo é você criar um novo projeto HardHat. Para isso, crie uma pasta na sua máquina, que eu vou chamar de token-erc20-hardhat. Abra o terminal e navegue até ela, executando os comandos para criação do projeto HardHat de exemplo (selecione a opção TypeScript e o resto deixe default).
1 2 3 4 5 |
npm init -y npm i -D hardhat npx hardhat |
Com o projeto criado, limpe as pastas contracts, scripts e test.
Na pasta contracts, crie somente um contrato nela, cujo nome do arquivo vai ser o nome da sua cripto, no meu caso: LuizCoin. Se você fez meu outro tutorial de token ERC-20, pode inclusive copiar e colar o código-fonte de lá para ganhar tempo, mas a abordagem que vou propor aqui é diferente e mais profissional pois vamos usar contratos da OpenZeppelin.
Não sabe o que é OpenZeppelin? Explico no vídeo a seguir.
1 2 3 4 5 6 7 8 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract LuizCoin { } |
A OpenZeppelin é uma empresa conhecida mundialmente na área de cripto e blockchain por oferecer produtos e serviços na área de segurança de aplicações decentralizadas. Como alguns códigos Solidity são muito frequentes, como os códigos de padrões ERC, e é muito fácil de você deixar brechas em smart contracts para atacantes, eles resolvem os dois problemas fornecendo bibliotecas de contratos open-source já testados e auditados extensivamente para você usar, além de serviços de auditoria requisitados por grandes players do mercado.
Assim, vamos instalar a biblioteca de contratos deles em nosso projeto e você verá os ganhos que ela vai nos trazer logo mais.
1 2 3 |
npm install @openzeppelin/contracts |
Repare que estou usando a versão 0.8.20 ou superior no meu contrato, você deve usar a versão que possuir instalada na sua máquina, a partir da que informei acima. E por fim, vamos deixar a estrutura de nossos testes preparada, criando na pasta test um arquivo LuizCoin.test.ts e deixando a estrutura abaixo nele.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; describe("LuizCoin", () => { const DECIMALS = 18n; async function deployFixture() { const [owner, otherAccount] = await ethers.getSigners(); const LuizCoin = await ethers.getContractFactory("LuizCoin"); const luizCoin = await LuizCoin.deploy(); return { luizCoin, owner, otherAccount }; } }); |
Aqui estamos carregando as importações necessárias, definindo uma suíte de testes (describe), criando a constante que define quantas casas decimais teremos na nossa moeda e criando a função de deploy fixture, que serve para o contrato ser provisionado apenas uma vez e limpo a cada teste, além de termos acesso às carteiras de teste.
Agora na pasta scripts vamos criar um arquivo deploy.ts colocando o código necessário para fazer o deploy do seu contrato apenas.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { ethers } from "hardhat"; async function main() { const LuizCoin = await ethers.getContractFactory("LuizCoin"); const luizCoin = await LuizCoin.deploy(); await luizCoin.waitForDeployment(); const address = await luizCoin.getAddress(); console.log(`Contract LuizCoin deployed to ${address}!`); } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main().catch((error) => { console.error(error); process.exitCode = 1; }); |
Agora temos o setup do nosso projeto de novo token pronto, é hora de programarmos ele!
#2 – Contrato do Token
Agora vamos nos concentrar no arquivo LuizCoin.sol, que é a implementação do nosso token ERC20. Como vamos usar a biblioteca de contratos da OpenZeppelin, vamos começar importando a mesma, logo no topo do arquivo e dizendo que nosso contrato vai herdar todas as características do contrato ERC20.sol existente na biblioteca do OpenZeppelin. Fazemos isso com a keyword ‘is’ ao lado do nome do contrato novo e antes do nome do contrato-pai, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 |
//SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract LuizCoin is ERC20 { } |
Com esta implementação agora nossa LuizCoin tem acesso a todas características e funções compartilhadas pelo contrato ERC20.sol, acelerando bastante o nosso desenvolvimento e reduzindo drasticamente a nossa chance de deixar brechas em nosso código pois ele será mais de customização do que de implementação.
Por exemplo, para definir o nome e symbol do nosso token, basta chamarmos o constructor da superclasse/classe-pai junto ao nosso constructor, como abaixo, passando os seus parâmetros.
1 2 3 4 5 |
constructor() ERC20("LuizCoin", "LUC"){ _mint(msg.sender, 1000 * 10 ** 18); } |
Aqui estou dizendo que o constructor de LuizCoin, quando chamado, deve invocar o constructor de ERC20 passando no primeiro argumento o nome da moeda e no segundo o symbol dela.
Também já realizei a implementação do código personalizado do constructor de LuizCoin, que vai fazer o minting, ou seja, a cunhagem/criação das moedas e a sua imediata transferência para uma carteira à sua escolha. Essa função _mint é herdade de ERC20 também e espera o endereço da carteira de destino e a quantidade de tokens a serem cunhados.
No exemplo, estou dizendo que a carteira do criador do contrato (lembrando que o constructor é chamado no deploy do contrato, então msg.sender é quem criou o mesmo na blockchain neste momento) vai receber 1000 tokens, o que será 0 nosso supply inicial. Eu multiplico a quantidade por 10 elevado na potência 18 porque quero que meu token tenha 18 casas decimais, como a maioria dos tokens ERC20 aliás, mas você pode alterar como quiser.
Com isso você já tem um token ERC20 funcional e se fizer deploy dele vai funcionar normalmente, como manda a especificação e tenho a certeza que você achou que seria mais complicado, certo?
Dá pra complicar, mas a base é essa aí mesmo.
Antes de pensar em complicar, vamos testar para ver se funciona mesmo.
#3 – Testes do Contrato
É aqui onde você vai escrever a maior quantidade de código e é onde também se mostrará necessário você ter feito o outro tutorial de token ERC20 onde escrevemos todo o contrato do zero, pois ali construímos o conhecimento de como tudo funciona, permitindo que a gente consiga saber como testar um token também.
Agora abra o arquivo LuizCoin.test.ts na pasta test e vamos escrever nosso primeiro teste, que irá verificar se o saldo total do token foi transferido para a carteira de quem fez o deploy do contrato (vulgo admin/owner).
1 2 3 4 5 6 7 8 |
it("Should put total supply LuizCoin in the admin account", async () => { const { luizCoin, owner } = await loadFixture(deployFixture); const balance = await luizCoin.balanceOf(owner.address); const totalSupply = 1n * 10n ** 18n; expect(balance).to.equal(totalSupply, "Total supply wasn't in the first account"); }); |
Começamos o teste pegando o saldo do contrato e comparamos ele com o big number do total supply que é 1000 multiplicado por 10 na potência 18. Repare como uso o novo tipo do TypeScript para big numbers, suportado pela versão mais recente da biblioteca EthersJS. Para isso, basta adicionar o sufixo ‘n’ após o número. Isso é necessário pois os operadores tradicionais do JS não suportam big numbers. No final das contas a asserção é simples, com uso de .to.equal.
Eu não vou ficar citando, mas a cada teste codificado, o ideal é rodar a bateria toda com o comando ‘npx hardhat test’ a ver se estão passando, ok? Outra alternativa é usar o comando ‘npx hardhat coverage’ que traz um relatório de cobertura de testes.
O teste seguinte é mais simples, para verificar se o nome do token foi definido corretamente.
1 2 3 4 5 6 7 |
it("Should has the correct name", async () => { const { luizCoin } = await loadFixture(deployFixture); const name = await luizCoin.name() as string; expect(name).to.equal("LuizCoin", "The name is wrong"); }); |
Nada de especial aqui, então vamos avançar para o próximo que é igualmente simples: verificar se o symbol foi definido corretamente.
1 2 3 4 5 6 7 |
it("Should has the correct symbol", async () => { const { luizCoin } = await loadFixture(deployFixture); const symbol = await luizCoin.symbol() as string; expect(symbol).to.equal("LUC", "The symbol is wrong"); }); |
E também outro bem simples que verifica se os decimals estão corretos.
1 2 3 4 5 6 7 |
it("Should has the correct decimals", async () => { const { luizCoin } = await loadFixture(deployFixture); const decimals = await luizCoin.decimals(); expect(decimals).to.equal(DECIMALS, "The decimals are wrong"); }); |
E agora sim, finalmente entramos em um teste mais complicado: transfer. Aqui teremos dois cenários: um de sucesso e um de fracasso. No primeiro cenário, sucesso, vamos fazer uma transferência de 1 token para outra das carteiras disponíveis nos testes. No entanto, antes de fazermos esta transferência, sugiro pegar os saldos do from e do to a fim de podermos comparar se a transferência deu certo depois.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
it("Should transfer", async () => { const qty = 1n * 10n ** 18n; const { luizCoin, owner, otherAccount } = await loadFixture(deployFixture); const balanceAdminBefore = await luizCoin.balanceOf(owner.address); const balanceToBefore = await luizCoin.balanceOf(otherAccount.address); await luizCoin.transfer(otherAccount.address, qty); const balanceAdminNow = await luizCoin.balanceOf(owner.address); const balanceToNow = await luizCoin.balanceOf(otherAccount.address); expect(balanceAdminNow).to.equal(balanceAdminBefore - qty, "The admin balance is wrong"); expect(balanceToNow).to.equal(balanceToBefore + qty, "The to balance is wrong"); }); |
A chamada da função transfer é bem direta e simples e ela é nativamente feita a partir (from) da conta owner que é quem faz o deploy do mesmo. Como é uma transferência direta e de um valor pequeno (que cabe no saldo), ela será aceita normalmente e como resultado os saldos estarão ajustados após a mesma, o que podemos conferir pedindo novamente o saldo e comparando nos expects.
Agora vamos fazer o cenário de fracasso. Para isso, vou fazer o mesmo teste, mas tentando transferir mais do que a conta owner possui. Isso deve dar um erro, então vamos capturar o erro e fazer o expect em cima dele com o to.be.revertedWithCustomError.
1 2 3 4 5 6 7 8 9 |
it("Should NOT transfer", async () => { const aboveSupply = 1001n * 10n ** 18n; const { luizCoin, owner, otherAccount } = await loadFixture(deployFixture); await expect(luizCoin.transfer(otherAccount.address, aboveSupply)) .to.be.revertedWithCustomError(luizCoin, "ERC20InsufficientBalance"); }); |
Repare que o big number que defini é acima do total supply em 1 token, já causando o erro de transferência inválida por fundos insuficientes. Isso que nem é considerado nos testes as taxas de gás, caso contrário eu nem precisaria ter usado 1001. Já o nome do CustomError eu peguei diretamente do contrato ERC20.sol da OpenZeppelin.
Avançando nos testes, agora vamos fazer o teste da função approve, que serve para dar permissão a outra carteira transferir uma quantidade x de nosso fundos. Para podermos fazer o “antes e depois”, temos de pegar o allowance que nada mais é do que a quantidade permitida em transferências delegadas.
1 2 3 4 5 6 7 8 9 10 11 |
it("Should approve", async () => { const qty = 1n * 10n ** 18n; const { luizCoin, owner, otherAccount } = await loadFixture(deployFixture); await luizCoin.approve(otherAccount.address, qty); const allowedAmount = await luizCoin.allowance(owner.address, otherAccount.address); expect(qty).to.equal(allowedAmount, "The allowed amount is wrong"); `` }); |
Nada muito diferente aqui se você já entende como funciona a função approve e a função allowance, então vamos avançar para a próxima, que é a função transferFrom, que permite transferências delegadas.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
it("Should transfer from", async () => { const qty = 1n * 10n ** 18n; const { luizCoin, owner, otherAccount } = await loadFixture(deployFixture); const allowanceBefore = await luizCoin.allowance(owner.address, otherAccount.address); const balanceAdminBefore = await luizCoin.balanceOf(owner.address); const balanceToBefore = await luizCoin.balanceOf(otherAccount.address); await luizCoin.approve(otherAccount.address, qty); const instance = luizCoin.connect(otherAccount); await instance.transferFrom(owner.address, otherAccount.address, qty); const allowanceNow = await luizCoin.allowance(owner.address, otherAccount.address); const balanceAdminNow = await luizCoin.balanceOf(owner.address); const balanceToNow = await luizCoin.balanceOf(otherAccount.address); expect(allowanceBefore).to.equal(allowanceNow, "The allowance is wrong"); expect(balanceAdminNow).to.equal(balanceAdminBefore - qty, "The admin balance is wrong"); expect(balanceToNow).to.equal(balanceToBefore + qty, "The to balance is wrong"); }); |
Aqui temos um teste bem semelhante ao transfer, porém com mais passos e mais verificações. No “antes e depois” eu quero ver se o allowance depois da transferência estará igual a antes da mesma, já que ele será “queimado”. Além disso, pego as variáveis de saldo antes e depois também para ver se a transferência de fato aconteceu. Agora sobre a transferência em si, precisamos primeiro aprovar ela e depois executá-la chamando o transferFrom, sem esquecer de primeiro usar a função connect para alterar a instância de conexão com o contrato, já que por padrão ele vem conectado com a conta owner. Neste exemplo, a carteira otherAccount está transferindo para si saldo da carteira owner conforme permissão prévia.
E para finalizar, vamos escrever o teste de falha em uma transferência delegada, o que é bem simples de fazer bastando não dar um approve antes do transferFrom.
1 2 3 4 5 6 7 8 9 |
it("Should NOT transfer from", async () => { const qty = 1n * 10n ** 18n; const { luizCoin, owner, otherAccount } = await loadFixture(deployFixture); await expect(luizCoin.transferFrom(owner.address, otherAccount.address, qty)) .to.be.revertedWithCustomError(luizCoin, "ERC20InsufficientAllowance"); }); |
Como essa tentativa de transferência irá disparar erro, vamos capturá-lo e fazer nossa asserção em cima do mesmo.
Agora experimente rodar a bateria de testes com ‘npxhardhat test’ e espero que você tenha o meso resultado que eu abaixo.
E com isso finalizamos mais este tutorial. Se quiser aprender como fazer deploy desta moeda em blockchain local, recomendo aprender a usar o Ganache neste tutorial ou a HardHat Network neste outro. Agora se quiser colocar em uma blockchain pública, recomendo fazer este tutorial aqui. E se quiser aprender a reduzir o consumo de gás nos seus smart contracts, este é o artigo certo.
E por fim, no vídeo abaixo eu ensino mais sobre a biblioteca OpenZeppelin Contracts, caso queira se aprofundar no assunto.
Um abraço e sucesso!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.