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 --init |
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.28; 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.28 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 |
import { expect } from "chai"; import { network } from "hardhat"; const { ethers, networkHelpers } = await network.connect(); const [owner, otherAccount] = await ethers.getSigners(); describe("LuizCoin", function () { }); |
Aqui estamos carregando as importações necessárias, definindo uma suíte de testes (describe) 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 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; 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 (TypeScript)
É 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. Vamos fazer primeiro os testes em TypeScript e a seguir vou mostrar os mesmos testes em Solidity.
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 |
it("Should get balance", async function () { const contract = await ethers.deployContract("LuizCoin"); const balance = await contract.balanceOf(owner.address); expect(balance).to.equal(1000n * 10n ** 18n); }); |
Começamos o teste pegando o saldo da carteira do owner 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 mocha’ a ver se estão passando, ok? O teste seguinte é mais simples, para verificar se o nome do token foi definido corretamente.
|
1 2 3 4 5 6 7 |
it("Should have correct name", async function () { const contract = await ethers.deployContract("LuizCoin"); const name = await contract.name(); expect(name).to.equal("LuizCoin"); }); |
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 have correct symbol", async function () { const contract = await ethers.deployContract("LuizCoin"); const symbol = await contract.symbol(); expect(symbol).to.equal("LUC"); }); |
E também outro bem simples que verifica se os decimals estão corretos.
|
1 2 3 4 5 6 7 |
it("Should have correct decimals", async function () { const contract = await ethers.deployContract("LuizCoin"); const decimals = await contract.decimals(); expect(decimals).to.equal(18); }); |
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 function () { const contract = await ethers.deployContract("LuizCoin"); const balanceOwnerBefore = await contract.balanceOf(owner.address); const balanceOtherBefore = await contract.balanceOf(otherAccount.address); await contract.transfer(otherAccount.address, 1n); const balanceOwnerAfter = await contract.balanceOf(owner.address); const balanceOtherAfter = await contract.balanceOf(otherAccount.address); expect(balanceOwnerBefore).to.equal(1000n * 10n ** 18n); expect(balanceOwnerAfter).to.equal((1000n * 10n ** 18n) - 1n); expect(balanceOtherBefore).to.equal(0n); expect(balanceOtherAfter).to.equal(1n); }); |
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 otherAccount 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 function () { const contract = await ethers.deployContract("LuizCoin"); const instance = contract.connect(otherAccount); await expect(instance.transfer(owner.address, 1n)) .to.be.revertedWithCustomError(contract, "ERC20InsufficientBalance"); }); |
Repare que o big number que defini é apenas 1, mas a conta otherAccount nasce zerada, causando o erro de transferência inválida por fundos insuficientes. Já o nome do CustomError eu peguei diretamente da implementação 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 |
it("Should approve", async function () { const contract = await ethers.deployContract("LuizCoin"); await contract.approve(otherAccount.address, 1n); const value = await contract.allowance(owner.address, otherAccount.address); expect(value).to.equal(1n); }); |
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 function () { const contract = await ethers.deployContract("LuizCoin"); const balanceOwnerBefore = await contract.balanceOf(owner.address); const balanceOtherBefore = await contract.balanceOf(otherAccount.address); await contract.approve(otherAccount.address, 10n); const instance = contract.connect(otherAccount); await instance.transferFrom(owner.address, otherAccount.address, 5n); const balanceOwnerAfter = await contract.balanceOf(owner.address); const balanceOtherAfter = await contract.balanceOf(otherAccount.address); const allowance = await contract.allowance(owner.address, otherAccount.address); expect(balanceOwnerBefore).to.equal(1000n * 10n ** 18n); expect(balanceOwnerAfter).to.equal((1000n * 10n ** 18n) - 5n); expect(balanceOtherBefore).to.equal(0n); expect(balanceOtherAfter).to.equal(5n); expect(allowance).to.equal(5n); }); |
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 os testes de falha em transferências delegadas, o que é bem simples de fazer bastando:
- 1) não ter saldo suficiente na conta do transferFrom no primeiro cenário;
- 2) não dar um approve antes do transferFrom no segundo cenário.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
it("Should NOT transfer from (balance)", async function () { const contract = await ethers.deployContract("LuizCoin"); const instance = contract.connect(otherAccount); await instance.approve(otherAccount.address, 1n); await expect(instance.transferFrom(otherAccount.address, otherAccount.address, 1n)) .to.be.revertedWithCustomError(contract, "ERC20InsufficientBalance"); }); it("Should NOT transfer from (allowance)", async function () { const contract = await ethers.deployContract("LuizCoin"); const instance = contract.connect(otherAccount); await expect(instance.transferFrom(owner.address, otherAccount.address, 1n)) .to.be.revertedWithCustomError(contract, "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 ‘npx hardhat test mocha’ e espero que você tenha o meso resultado que eu abaixo.

#4 – Testes do Contrato (Solidity)
Outra possibilidade é escrever os testes usando Solidity ao invés de TypeScript. Para isso, crie na pasta contracts um arquivo LuizCoin.t.sol e dentro dele escreva a seguinte estrutura inicial.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.28; import {LuizCoin} from "./LuizCoin.sol"; import {Test} from "forge-std/Test.sol"; import "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; contract LuizCoinTest is Test { LuizCoin luizCoin; address public owner; address public otherAccount; function setUp() public { owner = makeAddr("owner"); otherAccount = makeAddr("otherAccount"); vm.prank(owner); luizCoin = new LuizCoin(); } function compareStrings( string memory a, string memory b ) public pure returns (bool) { return keccak256(bytes(a)) == keccak256(bytes(b)); } } |
Aqui fiz as importações do contrato a ser testado, da biblioteca de testes do Forge e da biblioteca OpenZeppelin, mais especificamente do arquivo que contém os custom errors de Token ERC20 que vamos precisar para os testes de cenário de falha.
Depois de criarmos o contrato de teste (LuizCoinTest), definimos as variáveis locais da instância do contrato e duas carteiras que usaremos nos testes. Na função setUp inicializamos as duas carteiras e instância do contrato, usando o vm.prank para fazê-lo usando a conta do owner.
Por fim, adicionei uma função de comparação de strings, para usarmos em algumas validações que vamos ter.
Os primeiros 4 testes são bem objetivos e servem para testar se o nome, sigla, decimais e supply total estão corretamente definidos no contrato, como abaixo.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
function test_name() public view { require( compareStrings(luizCoin.name(), "LuizCoin"), "Name should be LuizCoin" ); } function test_symbol() public view { require( compareStrings(luizCoin.symbol(), "LUC"), "Symbol should be LUC" ); } function test_decimals() public view { require(luizCoin.decimals() == 18, "Decimals should be 18"); } function test_totalSupply() public view { require( luizCoin.totalSupply() == 1000 * 10 ** 18, "Total supply should be 1000 * 10 ** 18" ); } |
Os testes em si são bem simples, usando requires tradicionais do Solidity e para executá-los basta rodar o comando ‘npx hardhat test solidity’. A seguir, precisamos testar se o saldo da conta owner foi corretamente estabelecido, o que pode ser feito usando a função balanceOf.
|
1 2 3 4 5 6 7 8 |
function test_balanceOf() public view { require( luizCoin.balanceOf(owner) == 1000 * 10 ** 18, "Balance should be 1000 * 10 ** 18" ); } |
Agora vamos para o teste de transferência bem sucedida, que é o mais complexo até o momento.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
function test_transfer() public { vm.startPrank(owner); uint256 ownerBalanceBefore = luizCoin.balanceOf(owner); uint256 otherBalanceBefore = luizCoin.balanceOf(otherAccount); luizCoin.transfer(otherAccount, 1); uint256 ownerBalanceAfter = luizCoin.balanceOf(owner); uint256 otherBalanceAfter = luizCoin.balanceOf(otherAccount); require( ownerBalanceBefore == 1000 * 10 ** 18, "ownerBalanceBefore should be 1000 * 10 ** 18" ); require( ownerBalanceAfter == (1000 * 10 ** 18) - 1, "ownerBalanceAfter should be (1000 * 10 ** 18) - 1" ); require(otherBalanceBefore == 0, "otherBalanceBefore should be 0"); require(otherBalanceAfter == 1, "otherBalanceAfter should be 1"); vm.stopPrank(); } |
Começamos com o startPrank para definir que em toda a função vamos usar a conta owner para fazer as chamadas. Pegamos o saldo dela e do destinatário (otherAccount) antes e depois da transferência para fins de comparação e usamos requires para testar os valores corretos.
Para o teste de erro, usaremos o prank novamente para usar a otherAccount, pois como ela tem saldo zerado dará erro na transferência.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function test_transferError() public { vm.prank(otherAccount); vm.expectRevert( abi.encodeWithSelector( IERC20Errors.ERC20InsufficientBalance.selector, otherAccount, 0, 1 ) ); luizCoin.transfer(owner, 1); } |
Como a OpenZeppelin usa custom erros, é bem chatinho de programar o expectRevert corretamente, precisando usar o abi.encodeWithSelector em cima do custom error correto (ERC20InsufficientBalance) passando nos parâmetros qual conta quer transferir, qual o saldo dela e qual o valor a ser transferido.
Agora vamos ao teste de aprovação, sem nenhuma grande novidade em relação aos testes que já fizemos antes.
|
1 2 3 4 5 6 7 8 9 |
function test_approve() public { vm.startPrank(owner); luizCoin.approve(otherAccount, 1); uint256 value = luizCoin.allowance(owner, otherAccount); require(value == 1, "Allowance shoul be 1"); vm.stopPrank(); } |
Agora o teste de transferFrom, sempre mais complexo de fazer pois exige todo um setup inicial de aprovação e troca de contas. Já as comparações em si dependem da coleta dos saldos antes e depois, além do allowance restante.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function test_transferFrom() public { uint256 ownerBalanceBefore = luizCoin.balanceOf(owner); uint256 otherBalanceBefore = luizCoin.balanceOf(otherAccount); vm.prank(owner); luizCoin.approve(otherAccount, 10); vm.prank(otherAccount); luizCoin.transferFrom(owner, otherAccount, 5); uint256 ownerBalanceAfter = luizCoin.balanceOf(owner); uint256 otherBalanceAfter = luizCoin.balanceOf(otherAccount); uint256 allowance = luizCoin.allowance(owner, otherAccount); require( ownerBalanceBefore == 1000 * 10 ** 18, "ownerBalanceBefore should be 1000 * 10 ** 18" ); require( ownerBalanceAfter == (1000 * 10 ** 18) - 5, "ownerBalanceAfter should be (1000 * 10 ** 18) - 5" ); require(otherBalanceBefore == 0, "otherBalanceBefore should be 0"); require(otherBalanceAfter == 5, "otherBalanceAfter should be 5"); require(allowance == 5, "allowance should be 5"); } |
Agora vamos aos dois cenários de teste do transferFrom: saldo insuficiente e allowance insuficiente. Sem grandes novidades nesses dois testes, mas exigem uma atenção especial expectRevert de cada um que deve ser corretamente codificado com os parâmetros corretos no encodeWithSelector: no caso de transferFrom, eles são o seletor, a conta que vai transferir, o saldo na conta e o valor a ser transferido.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
function test_transferFromBalanceError() public { vm.startPrank(otherAccount); luizCoin.approve(otherAccount, 1); vm.expectRevert( abi.encodeWithSelector( IERC20Errors.ERC20InsufficientBalance.selector, otherAccount, 0, 1 ) ); luizCoin.transferFrom(otherAccount, otherAccount, 1); vm.stopPrank(); } function test_transferFromAllowanceError() public { vm.prank(otherAccount); vm.expectRevert( abi.encodeWithSelector( IERC20Errors.ERC20InsufficientAllowance.selector, otherAccount, 0, 1 ) ); luizCoin.transferFrom(owner, otherAccount, 1); } |
Ao rodar o npx hardhat teste solidity uma última vez, você deve ter o resultado abaixo.

E com isso finalizamos mais este tutorial. Se quiser aprender como fazer deploy desta moeda em blockchain local, recomendo aprender a usar o HardHat Network neste outro tutorial. 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.



