Saltar al contenido
TrautsLablab

Ejecutable local con Foundry y Anvil

ERC-20 con Foundry

Probar un ERC-20 mínimo con Foundry, desplegarlo en Anvil y observar transferencias y allowances con Cast.

Duración
45–60 min
Nivel
Intermedio Solidity
Pasos
3
Versión
3.29.0-alpha.1

Modelo mental

Un ERC-20 es una tabla de balances y otra de permisos. approve no mueve tokens: autoriza a otro a moverlos después, y ese permiso sigue vivo hasta que se consume o se revoca.

  1. 1Preflight
  2. 2Tests
  3. 3Deploy
  4. 4Transferencia
  5. 5Allowance
  6. 6Cleanup

Antes de empezar

  • Foundry instalado: forge, anvil y cast en el PATH (https://getfoundry.sh).
  • Primera ejecución con internet: forge descarga solc 0.8.24 si no lo tienes.
  • Node.js 20 o superior para el preflight.
  • Puerto local 8545 libre.

Pasos

  1. Paso 1Preparar el fixture y correr los tests

    Validar el contrato antes de desplegarlo.

    Preflight
    node tools/labs/ethereum-erc20-foundry-lab/preflight.mjs
    Fixture y tests
    mkdir -p .tmp/erc20-foundry-lab
    cp tools/labs/ethereum-erc20-foundry-lab/templates/foundry.toml .tmp/erc20-foundry-lab/foundry.toml
    cp -R tools/labs/ethereum-erc20-foundry-lab/templates/src .tmp/erc20-foundry-lab/src
    cp -R tools/labs/ethereum-erc20-foundry-lab/templates/test .tmp/erc20-foundry-lab/test
    cd .tmp/erc20-foundry-lab
    forge build
    forge test -vv

    Comprueba

    • — El preflight imprime las versiones de forge, anvil y cast y "Preflight OK".
    • — forge build termina con "Compiler run successful" (los avisos de lint sobre transfer sin chequear son esperables en los tests).
    • — forge test: 5 passed; 0 failed, incluido testFuzzTransferPreservesTotalSupply con 256 runs.

    Evidencia esperada

    Versiones y salida de forge test.

  2. Paso 2Desplegar en Anvil

    Llevar el contrato a una cadena local y leer su estado inicial.

    Terminal A: Anvil
    anvil --host 127.0.0.1 --port 8545 --chain-id 31337
    Deploy (desde .tmp/erc20-foundry-lab)
    export RPC_URL=http://127.0.0.1:8545
    export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    export FROM=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    export TO=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
    forge create src/LabToken.sol:LabToken --rpc-url $RPC_URL --private-key $PK --broadcast --constructor-args "TrautsLab Token" TLAB 1000000
    export TOKEN=<Deployed to>
    cast call $TOKEN "totalSupply()(uint256)" --rpc-url $RPC_URL
    cast call $TOKEN "balanceOf(address)(uint256)" $TO --rpc-url $RPC_URL

    Comprueba

    • — forge create imprime Deployer, "Deployed to" y "Transaction hash"; en un Anvil recién iniciado la dirección es 0x5FbDB2315678afecb367f032d93F642f64180aa3.
    • — totalSupply devuelve 1000000000000000000000000 [1e24]: un millón de tokens con 18 decimales.
    • — El balance de TO es 0.

    Evidencia esperada

    Dirección del contrato, hash del deployment y supply.

  3. Paso 3Transferir y probar el allowance

    Ver cómo cambia la tabla de balances y cómo un permiso deja mover tokens ajenos.

    Transferencia
    cast send $TOKEN "transfer(address,uint256)" $TO 100ether --private-key $PK --rpc-url $RPC_URL
    cast call $TOKEN "balanceOf(address)(uint256)" $FROM --rpc-url $RPC_URL
    cast call $TOKEN "balanceOf(address)(uint256)" $TO --rpc-url $RPC_URL
    Allowance
    cast send $TOKEN "approve(address,uint256)" $TO 50ether --private-key $PK --rpc-url $RPC_URL
    cast call $TOKEN "allowance(address,address)(uint256)" $FROM $TO --rpc-url $RPC_URL
    cast send $TOKEN "transferFrom(address,address,uint256)" $FROM $TO 50ether --from $TO --unlocked --rpc-url $RPC_URL
    cast call $TOKEN "allowance(address,address)(uint256)" $FROM $TO --rpc-url $RPC_URL

    Comprueba

    • — La transferencia tiene status 1 (success) y un log con el topic Transfer 0xddf252ad….
    • — FROM queda en 999900000000000000000000 [9.999e23] y TO en 100000000000000000000 [1e20].
    • — Tras approve, allowance devuelve 50000000000000000000 [5e19].
    • — TO mueve 50 tokens de FROM sin la clave de FROM y el allowance vuelve a 0.

    Evidencia esperada

    Hash de la transferencia, balances antes/después y la nota sobre approvals.

El código del laboratorio

Estos son los archivos que ejecutan los pasos, tal como están en el repositorio .

tools/labs/ethereum-erc20-foundry-lab/runbook.md
# Lab ERC-20 con Foundry

Versión: `1.3.0-alpha.1`

## Objetivo

Construir un ERC-20 mínimo, probar transferencias y allowances, desplegarlo en Anvil y documentar evidencia sin tocar redes ni claves reales.

## Límite de seguridad

- Red local únicamente.
- No usar seed phrases ni private keys reales.
- No apuntar `RPC_URL` a mainnet, Sepolia, Holesky ni proveedores externos.
- El fixture temporal vive en `.tmp/erc20-foundry-lab` y debe eliminarse al final.

## Ejecución corta

```bash
node tools/labs/ethereum-erc20-foundry-lab/preflight.mjs
mkdir -p .tmp/erc20-foundry-lab
cp tools/labs/ethereum-erc20-foundry-lab/templates/foundry.toml .tmp/erc20-foundry-lab/foundry.toml
cp -R tools/labs/ethereum-erc20-foundry-lab/templates/src .tmp/erc20-foundry-lab/src
cp -R tools/labs/ethereum-erc20-foundry-lab/templates/test .tmp/erc20-foundry-lab/test
cd .tmp/erc20-foundry-lab
forge build
forge test -vv
```

Luego levanta Anvil en otra terminal:

```bash
anvil --host 127.0.0.1 --port 8545 --chain-id 31337
```

Despliega con la clave prefundida de Anvil (cuenta 0, pública y solo válida en Anvil) y consulta con Cast desde `.tmp/erc20-foundry-lab`:

```bash
export RPC_URL=http://127.0.0.1:8545
export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export FROM=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
export TO=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
forge create src/LabToken.sol:LabToken --rpc-url $RPC_URL --private-key $PK --broadcast --constructor-args "TrautsLab Token" TLAB 1000000
export TOKEN=<Deployed to>
cast call $TOKEN "totalSupply()(uint256)" --rpc-url $RPC_URL
cast call $TOKEN "balanceOf(address)(uint256)" $TO --rpc-url $RPC_URL
cast send $TOKEN "transfer(address,uint256)" $TO 100ether --private-key $PK --rpc-url $RPC_URL
cast call $TOKEN "balanceOf(address)(uint256)" $FROM --rpc-url $RPC_URL
cast call $TOKEN "balanceOf(address)(uint256)" $TO --rpc-url $RPC_URL
```

Para la nota sobre approvals:

```bash
cast send $TOKEN "approve(address,uint256)" $TO 50ether --private-key $PK --rpc-url $RPC_URL
cast call $TOKEN "allowance(address,address)(uint256)" $FROM $TO --rpc-url $RPC_URL
cast send $TOKEN "transferFrom(address,address,uint256)" $FROM $TO 50ether --from $TO --unlocked --rpc-url $RPC_URL
cast call $TOKEN "allowance(address,address)(uint256)" $FROM $TO --rpc-url $RPC_URL
```

## Evidencia mínima

- versiones de `forge`, `anvil`, `cast`;
- salida de `forge test`;
- dirección del contrato;
- hash de deployment;
- hash de transferencia;
- balances antes/después;
- nota breve sobre el riesgo de approvals.

## Cleanup

```bash
rm -rf .tmp/erc20-foundry-lab
unset RPC_URL PK TOKEN FROM TO
```
tools/labs/ethereum-erc20-foundry-lab/preflight.mjs
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import net from 'node:net';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..', '..', '..');

const requiredTemplates = [
  'tools/labs/ethereum-erc20-foundry-lab/templates/foundry.toml',
  'tools/labs/ethereum-erc20-foundry-lab/templates/src/LabToken.sol',
  'tools/labs/ethereum-erc20-foundry-lab/templates/test/LabToken.t.sol',
];

const failures = [];
const warnings = [];

for (const binary of ['forge', 'anvil', 'cast']) {
  const result = spawnSync(binary, ['--version'], {
    encoding: 'utf8',
    shell: process.platform === 'win32',
  });

  if (result.error || result.status !== 0) {
    failures.push(`${binary} is not available in PATH`);
  } else {
    const firstLine = `${result.stdout || result.stderr}`.trim().split(/\r?\n/)[0];
    console.log(`${binary}: ${firstLine}`);
  }
}

for (const artifact of requiredTemplates) {
  if (!existsSync(path.join(root, artifact))) {
    failures.push(`Missing template artifact: ${artifact}`);
  }
}

const riskyEnv = ['PRIVATE_KEY', 'MNEMONIC', 'SEED_PHRASE', 'MAINNET_RPC_URL', 'ALCHEMY_API_KEY', 'INFURA_API_KEY'];
for (const key of riskyEnv) {
  if (process.env[key]) {
    warnings.push(`${key} is set. Do not use real credentials in this local lab.`);
  }
}

const rpcUrl = process.env.RPC_URL || '';
if (/mainnet|sepolia|holesky|alchemy|infura|quicknode/i.test(rpcUrl)) {
  failures.push(`RPC_URL looks non-local: ${rpcUrl}`);
}

function isPortOpen(port) {
  return new Promise((resolve) => {
    const socket = net.connect({ host: '127.0.0.1', port, timeout: 400 }, () => {
      socket.destroy();
      resolve(true);
    });

    socket.on('error', () => resolve(false));
    socket.on('timeout', () => {
      socket.destroy();
      resolve(false);
    });
  });
}

if (await isPortOpen(8545)) {
  warnings.push('Port 8545 is already open. That is fine if it is Anvil; otherwise choose another local port.');
}

if (warnings.length) {
  console.log('\nWarnings:');
  warnings.forEach((warning) => console.log(`- ${warning}`));
}

if (failures.length) {
  console.error('\nPreflight failed:');
  failures.forEach((failure) => console.error(`- ${failure}`));
  process.exit(1);
}

console.log('\nPreflight OK: ERC-20 Foundry lab can run locally.');
tools/labs/ethereum-erc20-foundry-lab/templates/foundry.toml
[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
ffi = false
tools/labs/ethereum-erc20-foundry-lab/templates/src/LabToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract LabToken {
    string public name;
    string public symbol;
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address account => uint256 balance) public balanceOf;
    mapping(address owner => mapping(address spender => uint256 amount)) public allowance;

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

    error InsufficientBalance();
    error InsufficientAllowance();
    error ZeroAddress();

    constructor(string memory tokenName, string memory tokenSymbol, uint256 initialSupply) {
        name = tokenName;
        symbol = tokenSymbol;
        _mint(msg.sender, initialSupply * 10 ** decimals);
    }

    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) {
        if (spender == address(0)) revert ZeroAddress();
        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];
        if (allowed < amount) revert InsufficientAllowance();

        allowance[from][msg.sender] = allowed - amount;
        emit Approval(from, msg.sender, allowance[from][msg.sender]);

        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        if (to == address(0)) revert ZeroAddress();
        if (balanceOf[from] < amount) revert InsufficientBalance();

        unchecked {
            balanceOf[from] -= amount;
            balanceOf[to] += amount;
        }

        emit Transfer(from, to, amount);
    }

    function _mint(address to, uint256 amount) internal {
        if (to == address(0)) revert ZeroAddress();
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }
}
tools/labs/ethereum-erc20-foundry-lab/templates/test/LabToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {LabToken} from "../src/LabToken.sol";

contract TokenActor {
    function approve(LabToken token, address spender, uint256 amount) external returns (bool) {
        return token.approve(spender, amount);
    }
}

contract LabTokenTest {
    LabToken internal token;
    TokenActor internal alice;
    address internal bob = address(0xB0B);

    function setUp() public {
        token = new LabToken("TrautsLab Token", "TLAB", 1_000_000);
        alice = new TokenActor();
    }

    function testInitialSupplyBelongsToDeployer() public {
        assertEq(token.totalSupply(), 1_000_000 ether);
        assertEq(token.balanceOf(address(this)), 1_000_000 ether);
    }

    function testTransferMovesBalance() public {
        assertTrue(token.transfer(address(alice), 100 ether));
        assertEq(token.balanceOf(address(alice)), 100 ether);
        assertEq(token.balanceOf(address(this)), 999_900 ether);
    }

    function testApproveAndTransferFrom() public {
        token.transfer(address(alice), 250 ether);

        assertTrue(alice.approve(token, address(this), 50 ether));

        assertTrue(token.transferFrom(address(alice), bob, 40 ether));

        assertEq(token.balanceOf(bob), 40 ether);
        assertEq(token.allowance(address(alice), address(this)), 10 ether);
    }

    function testRevertWhenAllowanceIsTooSmall() public {
        token.transfer(address(alice), 10 ether);

        alice.approve(token, address(this), 1 ether);

        try token.transferFrom(address(alice), bob, 2 ether) returns (bool ok) {
            require(!ok, "transferFrom should fail");
        } catch {
            return;
        }

        revert("expected insufficient allowance revert");
    }

    function testFuzzTransferPreservesTotalSupply(uint256 rawAmount) public {
        uint256 amount = (rawAmount % token.balanceOf(address(this))) + 1;
        uint256 supplyBefore = token.totalSupply();

        token.transfer(address(alice), amount);

        assertEq(token.totalSupply(), supplyBefore);
        assertEq(token.balanceOf(address(this)) + token.balanceOf(address(alice)), supplyBefore);
    }

    function assertTrue(bool value) internal pure {
        require(value, "expected true");
    }

    function assertEq(uint256 actual, uint256 expected) internal pure {
        require(actual == expected, "uint256 mismatch");
    }
}

Criterios de aceptación

  • forge test muestra 5 passed.
  • El contrato se desplegó y los balances cuadran con el supply.
  • Puedes explicar por qué un approve ilimitado a un contrato desconocido es un riesgo.
  • El fixture y Anvil quedaron eliminados.

Limpieza

Fixture y variables
cd ../..
rm -rf .tmp/erc20-foundry-lab
unset RPC_URL PK TOKEN FROM TO

Anvil: Ctrl+C en la terminal A.

Si algo falla

Síntoma, causa probable y cómo corregirlo
SíntomaCausaQué hacer
forge build no puede descargar solcSin acceso a binaries.soliditylang.org en la primera ejecución.Conéctate una vez para que forge guarde solc 0.8.24 en ~/.svm, o instálalo con svm.
forge create no despliega y solo simulaFalta --broadcast en Foundry 1.x.Agrega --broadcast como en el comando del paso.
La dirección del contrato no es 0x5FbDB…La cuenta ya había enviado transacciones; la dirección depende del nonce.Usa la dirección impresa en "Deployed to" o reinicia Anvil.