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:
transfertransfere token do chamador (msg.sender) parato. - Delegação:
approvepermite que umspendergaste atévaluedo seu token. Depois, ospenderchamatransferFrompara 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
publicgeram getters automaticamente. Declararmapping(...) public balanceOfgera a funçãobalanceOf(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
Transferdeaddress(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. transferFromsubtrai o allowance antes de transferir. Se isso não fosse feito, ospenderpoderia 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,requirecom 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ãoaddress(this)possui os 1000 tokens inteiros.1_000 etheré apenas uma forma concisa de escrever1000 * 10^18. A palavra-chaveetheraqui apenas multiplica por1e18, 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 tenhamsg.sendercomoalice.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.expectRevertafirma 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")definenameesymbol. Por padrão,decimals()retorna 18._mintaumentatotalSupply, adiciona saldo ao destinatário e emite um eventoTransferdeaddress(0).Ownable(initialOwner)(OpenZeppelin v5) requer passar o proprietário explicitamente. O modificadoronlyOwnerimpede que outros chamemmint.
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
ERC20do OpenZeppelin e adicione apenas sua lógica personalizada.
- Confundir
decimals. Chamartransfer(to, 100)transfere apenas100 * 10^-18token, praticamente zero. Sempre multiplique por10 ** decimals(). Por outro lado, USDC usa 6 decimals, não 18, então não faça hard-code de1e18ao 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 suportampermit(ERC-2612). - Tokens fora de padrão. Alguns tokens antigos como USDT não retornam
booldetransfer. Quando seu contrato chama um token externo, use a bibliotecaSafeERC20(using SafeERC20 for IERC20;depois chamesafeTransfer) em vez de chamartransferdiretamente. - 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. mintsem limite. Uma funçãomintcomonlyOwnersignifica 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, useERC20Capped.
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.