ERC-20: Escribe tu primer token con Solidity y Foundry

1. Descripción general

ERC-20 es el estándar de token más popular en Ethereum. USDC, DAI, LINK y miles de otros tokens lo siguen. En este artículo, escribirás un token ERC-20 minimalista desde cero para entender cómo funciona el estándar. Luego escribirás tests con Foundry. A continuación, pasarás a la implementación de OpenZeppelin para un entorno real y despliegas el token en un blockchain local.

2. ¿Qué es ERC-20?

ERC-20 es una interfaz, es decir, un conjunto de funciones y eventos que todo token debe tener. Gracias a esta interfaz común, billeteras, exchanges y contratos DeFi pueden trabajar con cualquier token de la misma manera sin necesidad de saber cómo está escrito 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);
}

Puedes dividir esta interfaz en dos grupos:

  • Transferencia directa: transfer transfiere tokens desde el llamador (msg.sender) a to.
  • Delegación: approve permite que un spender gaste como máximo value tokens tuyos. Después, el spender llama a transferFrom para retirar. Este es el mecanismo que permite a un DEX “jalar” tokens desde tu billetera cuando haces un swap.

Las tres funciones name(), symbol() y decimals() pertenecen a la extensión metadata. En teoría no son obligatorias, pero casi todos los tokens las tienen.

3. Preparar el entorno con Foundry

Este artículo usa Foundry: escribes tanto el contrato como los tests en Solidity, y los tests se ejecutan muy rápido. Después de instalar Foundry, crea un proyecto:

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

El comando forge install descarga la librería OpenZeppelin en el directorio lib/. Necesitas declarar un remapping para hacer los imports más cortos. Agrega la siguiente línea al archivo remappings.txt en la raíz del proyecto:

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

La estructura del proyecto consta de src/ para los contratos, test/ para los tests y script/ para los scripts de despliegue. Puedes eliminar los archivos de ejemplo Counter que forge init crea.

4. Escribir un ERC-20 minimalista desde cero

El propósito de esta sección es entender el estándar ERC-20, no para usarlo en producción. Crea el archivo 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);
    }
}

Puntos importantes a notar:

  • Las variables de estado public generan getters automáticamente. Declarar mapping(...) public balanceOf genera una función balanceOf(address) que coincide exactamente con la firma en la interfaz. Lo mismo aplica a allowance.
  • decimals = 18. La blockchain no tiene números decimales, así que el token almacena todos los saldos como números enteros. Cuando una billetera muestra “1.5 CAFE”, el valor real en el contrato es 1.5 * 10^18.
  • El constructor emite un evento Transfer desde address(0). Esta es la convención para indicar que el token fue creado (mint). Los block explorers se basan en este evento para mostrar el historial de transacciones.
  • transferFrom resta la delegación antes de transferir. Si no lo hiciera, el spender podría retirar indefinidamente.
  • No necesitas SafeMath. A partir de Solidity 0.8, la operación -= automáticamente revierte en caso de underflow. Aun así, usar require con un mensaje claro ayuda a depurar más fácilmente.

5. Escribir tests con Foundry

Los tests en Foundry son contratos que heredan de Test. Cada función que comienza con test es un caso de prueba. Crea el archivo 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);
    }
}

Explicación de cada parte:

  • setUp() se ejecuta antes de cada test. El contrato de test es quien despliega el token, por lo que address(this) posee los 1000 tokens iniciales.
  • 1_000 ether es solo una forma más corta de escribir 1000 * 10^18. La palabra clave ether aquí simplemente multiplica por 1e18, no tiene relación con ETH real.
  • makeAddr("alice") crea una dirección etiquetada, lo que hace que los logs sean más legibles cuando un test falla.
  • vm.prank(alice) hace que la siguiente llamada tenga msg.sender como alice.
  • vm.expectEmit(true, true, false, true) verifica los primeros dos tópicos (from, to) y la sección de datos (value) del evento que se emita inmediatamente después.
  • vm.expectRevert afirma que la siguiente llamada debe fallar con el mensaje exacto especificado.

Ejecuta los tests:

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

El consumo de gas en tu máquina puede variar ligeramente dependiendo de la versión del compilador.

6. Usar OpenZeppelin para un token real

La implementación manual anterior funciona, pero no ha sido auditada. Para un token real, deberías heredar de ERC20 de OpenZeppelin. Esta librería ha sido verificada con miles de millones de dólares en valor circulante. Crea el archivo 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);
    }
}

Con solo unas pocas líneas, tienes un token completamente funcional:

  • ERC20("Cafe Token", "CAFE") establece el name y symbol. Por defecto, decimals() devuelve 18.
  • _mint incrementa totalSupply, suma el saldo del destinatario y emite un evento Transfer desde address(0).
  • Ownable(initialOwner) (OpenZeppelin v5) requiere pasar explícitamente el owner. El modificador onlyOwner impide que otros llamen a mint.

OpenZeppelin v5 usa custom errors en lugar de strings de revert. Por lo tanto, los tests también deben verificar según el selector de error. Crea el archivo 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);
    }
}

Cuando una transferencia falla, ERC20InsufficientBalance(sender, balance, needed) te dice exactamente quién tiene fondos insuficientes y cuánto le falta. Esta información es mucho más útil que un mensaje genérico.

7. Desplegar en una red local e interactuar

Foundry incluye anvil, un nodo Ethereum local que ejecuta 10 cuentas precargadas con ETH. Abre una terminal separada y ejecuta:

anvil

En otra terminal, despliega el token usando la primera cuenta de anvil. Esta clave privada es pública, solo para uso local:

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...

Usa cast para leer saldos y transferir tokens:

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 solo lee datos, así que no gasta gas. cast send crea una transacción real en anvil. El argumento 50ether se convierte automáticamente por cast a 50 * 10^18.

8. Errores comunes y consideraciones

Advertencia: No escribas tu propio ERC-20 para producción. La implementación manual de la sección 4 solo sirve para aprender. Para un token real, hereda de OpenZeppelin y agrega solo tu lógica personalizada.

  • Confundir decimals. Llamar a transfer(to, 100) solo transfiere 100 * 10^-18 tokens, casi nada. Siempre multiplica por 10 ** decimals(). Inversamente, USDC usa 6 decimals en lugar de 18, así que no hardcodees 1e18 cuando interactúes con tokens de terceros.
  • Condición de carrera en approve. Supongamos que cambias la delegación de 100 a 50. Un spender malintencionado podría monitorear el mempool, retirar 100 antes de que se confirme tu comando, y luego retirar otros 50. Una forma segura es establecer la delegación en 0 primero, o usar tokens que soporten permit (ERC-2612).
  • Tokens que no siguen el estándar. Algunos tokens antiguos como USDT no devuelven bool desde transfer. Cuando tu contrato llama a tokens externos, usa la librería SafeERC20 (using SafeERC20 for IERC20; y luego llama safeTransfer) en lugar de llamar a transfer directamente.
  • Tokens enviados a un contrato quedan atrapados. ERC-20 no tiene un mecanismo para notificar al receptor. Si alguien envía tokens a un contrato que no tiene función de retiro, esos tokens se bloquearán permanentemente.
  • Tokens con tarifa y tokens rebasados. Hay tokens donde la cantidad recibida es menor que la enviada, o el saldo cambia automáticamente con el tiempo. Si tu contrato recibe cualquier token, mide el saldo antes y después de la transferencia en lugar de confiar en el parámetro amount.
  • mint sin límite. Una función mint con onlyOwner significa que el propietario puede acuñar tokens indefinidamente. Si quieres un suministro total fijo, elimina esta función o establece un límite máximo, por ejemplo, usando ERC20Capped.

9. Conclusión

En este artículo, entendiste la interfaz ERC-20 y escribiste un token minimalista desde cero para ver cómo balanceOf, allowance, transfer y transferFrom trabajan juntos. También escribiste tests con Foundry usando vm.prank, vm.expectEmit y vm.expectRevert. Luego pasaste a CafeToken basado en OpenZeppelin, lo desplegaste en anvil e interactuaste a través de cast.

El siguiente paso lógico es explorar extensiones como ERC20Permit para aprobar con firma, ERC20Burnable para quemar tokens, o ERC20Capped para limitar el suministro total. También deberías practicar escribir contratos que reciban tokens de forma segura usando SafeERC20.

solidityerc-20ethereumfoundrysmart-contract