ERC-20: escreva seu primeiro token com Solidity e Foundry

1. Visão geral

ERC-20 é o padrão de token mais comum no Ethereum. USDC, DAI, LINK e milhares de outros tokens seguem este padrão. Neste artigo, você escreverá um token ERC-20 mínimo por conta própria para entender como o padrão funciona, depois escreverá testes com Foundry. Em seguida, você mudará para a implementação do OpenZeppelin para um ambiente de produção e implantará o token em um blockchain local.

2. O que é ERC-20?

ERC-20 é uma interface, ou seja, um conjunto de funções e eventos que todo token deve ter. Graças a essa interface comum, carteiras, exchanges e contratos DeFi podem trabalhar com qualquer token da mesma forma, sem precisar saber como o token foi implementado internamente.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

Você pode dividir essa interface em dois grupos:

  • Transferência direta: transfer transfere token do chamador (msg.sender) para to.
  • Delegação: approve permite que um spender gaste até value do seu token. Depois, o spender chama transferFrom para sacar. Este é o mecanismo que permite que um DEX “puxe” token da sua carteira quando você faz um swap.

As três funções name(), symbol() e decimals() fazem parte da extensão metadata. Em teoria, não são obrigatórias, mas praticamente todo token as tem.

3. Preparando o ambiente com Foundry

Este artigo usa Foundry: você escreve contratos e testes ambos em Solidity, e os testes rodam muito rápido. Após instalar Foundry, crie um projeto:

forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts

O comando forge install baixa a biblioteca OpenZeppelin no diretório lib/. Você precisa declarar um remapping para importações mais concisas. Adicione a seguinte linha ao arquivo remappings.txt no diretório raiz:

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/

A estrutura do projeto inclui src/ para contratos, test/ para testes e script/ para scripts de implantação. Você pode remover os arquivos de exemplo Counter criados por forge init.

4. Escrever um ERC-20 mínimo por conta própria

O objetivo desta seção é entender o padrão ERC-20, não usá-lo na prática. Crie o arquivo src/SimpleToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SimpleToken {
    string public name = "Cafe Token";
    string public symbol = "CAFE";
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "allowance too low");
        allowance[from][msg.sender] = allowed - amount;
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(to != address(0), "zero address");
        require(balanceOf[from] >= amount, "balance too low");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}

Pontos dignos de nota:

  • Variáveis de estado public geram getters automaticamente. Declarar mapping(...) public balanceOf gera a função balanceOf(address), que corresponde exatamente à assinatura na interface. allowance é semelhante.
  • decimals = 18. Blockchains não têm números decimais, então o token armazena todos os saldos como números inteiros. Quando a carteira mostra “1.5 CAFE”, o valor real no contrato é 1.5 * 10^18.
  • O constructor emite um evento Transfer de address(0). Esta é uma convenção para indicar “token foi criado” (mint). Os exploradores de blocos usam esse evento para exibir o histórico de transações.
  • transferFrom subtrai o allowance antes de transferir. Se isso não fosse feito, o spender poderia sacar um número ilimitado de vezes.
  • Não é necessário SafeMath. A partir do Solidity 0.8, a operação -= reverterá automaticamente em caso de underflow. Mesmo assim, require com mensagens claras ainda ajuda na depuração.

5. Escrever testes com Foundry

Testes no Foundry são um contrato que herda Test. Cada função que começa com test é um caso de teste. Crie o arquivo test/SimpleToken.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {SimpleToken} from "../src/SimpleToken.sol";

contract SimpleTokenTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 value);

    SimpleToken token;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        token = new SimpleToken(1_000 ether);
    }

    function test_transfer_movesBalance() public {
        token.transfer(alice, 100 ether);

        assertEq(token.balanceOf(alice), 100 ether);
        assertEq(token.balanceOf(address(this)), 900 ether);
    }

    function test_transfer_emitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(address(this), alice, 1 ether);

        token.transfer(alice, 1 ether);
    }

    function test_transferFrom_requiresAllowance() public {
        token.transfer(alice, 100 ether);

        vm.prank(alice);
        token.approve(bob, 30 ether);

        vm.prank(bob);
        token.transferFrom(alice, bob, 30 ether);

        assertEq(token.balanceOf(bob), 30 ether);
        assertEq(token.allowance(alice, bob), 0);

        vm.prank(bob);
        vm.expectRevert(bytes("allowance too low"));
        token.transferFrom(alice, bob, 1);
    }
}

Explicação de cada parte:

  • setUp() é executado novamente antes de cada teste. O contrato de teste é quem implanta o token, então address(this) possui os 1000 tokens inteiros.
  • 1_000 ether é apenas uma forma concisa de escrever 1000 * 10^18. A palavra-chave ether aqui apenas multiplica por 1e18, não tem nada a ver com ETH real.
  • makeAddr("alice") cria um endereço com um rótulo, tornando os logs mais legíveis quando um teste falha.
  • vm.prank(alice) faz com que a próxima chamada tenha msg.sender como alice.
  • vm.expectEmit(true, true, false, true) verifica o tópico 1 (from), tópico 2 (to) e a parte de dados (value) do evento emitido imediatamente depois.
  • vm.expectRevert afirma que a próxima chamada deve reverter com exatamente a mensagem especificada.

Execute os testes:

forge test -vv
Ran 3 tests for test/SimpleToken.t.sol:SimpleTokenTest
[PASS] test_transferFrom_requiresAllowance() (gas: 79120)
[PASS] test_transfer_emitsEvent() (gas: 41871)
[PASS] test_transfer_movesBalance() (gas: 43552)
Suite result: ok. 3 passed; 0 failed; 0 skipped

O valor de gas na sua máquina pode diferir ligeiramente dependendo da versão do compilador.

6. Usar OpenZeppelin para um token de verdade

A implementação auto-escrita acima funciona, mas não foi auditada. Para tokens de verdade, você deve herdar de ERC20 do OpenZeppelin. Esta biblioteca foi verificada em bilhões de dólares em valor em circulação. Crie o arquivo src/CafeToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeToken is ERC20, Ownable {
    constructor(address initialOwner)
        ERC20("Cafe Token", "CAFE")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 1_000_000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
}

Com apenas uma dúzia de linhas, você tem um token completo:

  • ERC20("Cafe Token", "CAFE") define name e symbol. Por padrão, decimals() retorna 18.
  • _mint aumenta totalSupply, adiciona saldo ao destinatário e emite um evento Transfer de address(0).
  • Ownable(initialOwner) (OpenZeppelin v5) requer passar o proprietário explicitamente. O modificador onlyOwner impede que outros chamem mint.

OpenZeppelin v5 usa custom error em vez de strings de revert. Portanto, os testes também devem verificar o seletor de erro. Crie o arquivo test/CafeToken.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {CafeToken} from "../src/CafeToken.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeTokenTest is Test {
    CafeToken token;
    address owner = makeAddr("owner");
    address alice = makeAddr("alice");

    function setUp() public {
        token = new CafeToken(owner);
    }

    function test_initialSupply_goesToOwner() public view {
        assertEq(token.totalSupply(), 1_000_000e18);
        assertEq(token.balanceOf(owner), 1_000_000e18);
    }

    function test_transfer_revertsWhenBalanceTooLow() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 1)
        );
        token.transfer(owner, 1);
    }

    function test_mint_onlyOwner() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)
        );
        token.mint(alice, 1e18);
    }
}

Quando transfer reverte, ERC20InsufficientBalance(sender, balance, needed) informa exatamente quem está sem fundos e quanto falta. Essa informação é muito mais útil do que uma string genérica.

7. Implantar em uma rede local e interagir

Foundry vem com anvil, um nó Ethereum rodando localmente com 10 contas pré-carregadas com ETH. Abra um terminal separado e execute:

anvil

Em outro terminal, implante o token usando a primeira conta do anvil. Esta chave privada é publicamente conhecida, use apenas em redes locais:

export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

forge create src/CafeToken.sol:CafeToken \
  --rpc-url http://127.0.0.1:8545 \
  --private-key $PK \
  --broadcast \
  --constructor-args $OWNER
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x...

Use cast para ler saldo e transferir token:

export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3

cast call $TOKEN "balanceOf(address)(uint256)" $OWNER --rpc-url http://127.0.0.1:8545

cast send $TOKEN "transfer(address,uint256)" \
  0x70997970C51812dc3A010C7d01b50e0d17dc79C8 50ether \
  --private-key $PK --rpc-url http://127.0.0.1:8545
1000000000000000000000000 [1e24]

cast call apenas lê dados, portanto não consome gas. cast send cria uma transação real no anvil. O argumento 50ether é automaticamente convertido por cast para 50 * 10^18.

8. Erros comuns e considerações

Aviso: Não escreva ERC-20 por conta própria para produção. A implementação na seção 4 é apenas para aprendizado. Para tokens reais, herde de ERC20 do OpenZeppelin e adicione apenas sua lógica personalizada.

  • Confundir decimals. Chamar transfer(to, 100) transfere apenas 100 * 10^-18 token, praticamente zero. Sempre multiplique por 10 ** decimals(). Por outro lado, USDC usa 6 decimals, não 18, então não faça hard-code de 1e18 ao trabalhar com tokens de terceiros.
  • Condição de corrida de approve. Suponha que você reduza o allowance de 100 para 50. Um spender mal-intencionado pode monitorar o mempool, sacar 100 antes que o novo comando seja confirmado, e depois sacar mais 50. A forma segura é definir allowance para 0 primeiro, ou usar tokens que suportam permit (ERC-2612).
  • Tokens fora de padrão. Alguns tokens antigos como USDT não retornam bool de transfer. Quando seu contrato chama um token externo, use a biblioteca SafeERC20 (using SafeERC20 for IERC20; depois chame safeTransfer) em vez de chamar transfer diretamente.
  • Token enviado para contrato fica preso. ERC-20 não tem mecanismo para notificar o receptor. Se alguém enviar token para um contrato que não tem função de saque, esse token ficará trancado permanentemente.
  • Tokens com taxa e rebasing. Alguns tokens recebem menos do que é enviado, ou os saldos mudam automaticamente com o tempo. Se seu contrato recebe qualquer token, meça o saldo antes e depois da transferência, em vez de confiar no parâmetro amount.
  • mint sem limite. Uma função mint com onlyOwner significa que o proprietário pode cunhar mais token indefinidamente. Se você quiser uma oferta fixa, remova essa função ou defina um teto, por exemplo, use ERC20Capped.

9. Conclusão

Neste artigo, você aprendeu a interface ERC-20 e implementou um token mínimo por conta própria para ver claramente como balanceOf, allowance, transfer e transferFrom trabalham juntos. Você também escreveu testes com Foundry usando vm.prank, vm.expectEmit e vm.expectRevert. Depois disso, mudou para CafeToken baseado em OpenZeppelin, implantou no anvil e interagiu via cast.

O próximo passo lógico é tentar extensões como ERC20Permit para aprovar com assinatura, ERC20Burnable para queimar token, ou ERC20Capped para limitar a oferta total. Você também deve praticar a escrita de contratos que recebem token com segurança usando SafeERC20.

solidityerc-20ethereumfoundrysmart-contract