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.
- 1Preflight
- 2Tests
- 3Deploy
- 4Transferencia
- 5Allowance
- 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
Paso 1Preparar el fixture y correr los tests
Validar el contrato antes de desplegarlo.
Preflight node tools/labs/ethereum-erc20-foundry-lab/preflight.mjsFixture 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 -vvComprueba
- — 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.
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 31337Deploy (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_URLComprueba
- — 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.
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_URLAllowance 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_URLComprueba
- — 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 .
# 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
```#!/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.');[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
ffi = false// 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);
}
}// 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
cd ../..
rm -rf .tmp/erc20-foundry-lab
unset RPC_URL PK TOKEN FROM TOAnvil: Ctrl+C en la terminal A.
Si algo falla
| Síntoma | Causa | Qué hacer |
|---|---|---|
| forge build no puede descargar solc | Sin 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 simula | Falta --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. |