📑 Índice del documento
- Blockchain
· Clase 2 — ERC-20 + PaymentGateway + Reentrancy
- ¿Qué vamos a hacer hoy?
- Parte 1 — Refresher rápido
- Parte 2 — ERC-20: el estándar de tokens
- Parte 3 — OpenZeppelin: no reinventes la rueda
- Parte 4 — El contrato
PaymentGateway - Parte 5 — Tests con Foundry
- Parte 6 — Reentrancy: el ataque que rompió The DAO
- Parte 7 — Deploy del PaymentGateway a Sepolia
- Parte 8 — Probar el flow completo
- Parte 9 — Tu propio ERC-20 (token del proyecto)
- Cierre — qué nos llevamos
- Tarea para próxima clase
Blockchain · Clase 2 — ERC-20 + PaymentGateway + Reentrancy
Objetivo de la clase: dejar el
SimpleStorageatrás y escribir el contrato que su sistema realmente va a usar — unPaymentGatewayque cobra USDC en Sepolia, emite eventosPaidque su backend va a consumir, y está protegido contra el ataque clásico de reentrancy. Al final tienen el contrato deployado y testeado.
Pre-requisito: la clase 1 cerrada. Tienen MetaMask + Sepolia + Foundry instalado, y un
SimpleStoragedeployado y verificado.
Repo: la clase ya tiene su carpeta lista en el monorepo del módulo:
sip2026-blockchain/clase-2. TraePaymentGateway.sol,ProjectToken.sol, tests y script de deploy. Si vienen de clase 1, basta concd ../clase-2desde el repo clonado.
🎯 Lo que te vas a llevar al final de hoy:
¿Qué vamos a hacer hoy?
- Refresher del modelo mental: el contrato como “MercadoPago en blockchain” (5 min).
- ¿Qué es ERC-20? El estándar de tokens fungibles — la moneda del sistema.
- OpenZeppelin — la librería de contratos battle-tested que vamos a usar.
- El contrato
PaymentGatewaypaso a paso. - Tests con Foundry — happy path + edge cases + protección contra reentrancy.
- El ataque de reentrancy — cómo se rompía el contrato sin protección, y cómo se cierra.
- Deploy del PaymentGateway a Sepolia.
- Probar el flow completo: aprobar USDC → llamar
pay()→ ver el eventoPaiden Etherscan.
Al cerrar: cada equipo tiene un PaymentGateway suyo deployado. Ese es el contrato base que en clase 4 van a extender con la lógica específica de su proyecto (VibeCheck/DepFund/RNW/IDEAFY).
Parte 1 — Refresher rápido
Dijimos que vamos a integrar pagos en blockchain como integraríamos MercadoPago. Recordemos:
| MercadoPago | PaymentGateway en Sepolia |
|---|---|
MercadoPago.charge(buyer, amount) (HTTP API) |
gateway.pay(amount, action) (tx firmada) |
| Webhook que avisa “el pago se procesó” | Evento Paid(payer, amount, action) que su indexer
consume |
| Saldo del comprador en pesos | Saldo del comprador en USDC (un ERC-20) |
| MercadoPago como custodio de los pesos | El contrato treasury recibe los USDC directamente |
| Comisión de MP (~5%) | Gas (gratis en Sepolia con ETH del faucet, ~$0.01 en Polygon mainnet) |
🎯 Clave: si entendieron MercadoPago como integración HTTP-API, esto es el mismo modelo mental pero cambiando los rieles. Lo que cambia: cómo se firman las txs y cómo se mueven los activos.
Parte 2 — ERC-20: el estándar de tokens
ERC-20 es la “interface” de Solidity que todos los tokens fungibles cumplen (USDC, USDT, DAI, su propio token si crean uno). Estandarizar la interface significa que cualquier contrato puede recibir cualquier ERC-20 sin escribir código a medida.
Las 6 funciones de ERC-20
| Función | Qué hace | Quién la llama |
|---|---|---|
totalSupply() |
Total de tokens emitidos | Cualquiera (read) |
balanceOf(address) |
Saldo de un usuario | Cualquiera (read) |
transfer(to, amount) |
Mover tokens del caller a to |
El dueño de los tokens |
approve(spender, amount) |
“Autorizo a spender a gastar hasta amount
tokens en mi nombre” |
El dueño de los tokens |
allowance(owner, spender) |
Cuánto le queda autorizado a spender |
Cualquiera (read) |
transferFrom(from, to, amount) |
Si from me autorizó, mover amount de
from a to |
Un contrato (típicamente) |
El patrón “approve + transferFrom”
Es el corazón de cómo cobra un contrato:
- El usuario llama
usdc.approve(gateway, 50)→ “le doy permiso al PaymentGateway de gastar hasta 50 USDC míos”. - El usuario llama
gateway.pay(50, ...)→ el PaymentGateway internamente llamausdc.transferFrom(user, treasury, 50)y los USDC se mueven.
Son dos transacciones. ¿Por qué? Seguridad: el usuario decide explícitamente cuánto autoriza antes de que el contrato pueda mover sus fondos. Si el contrato es malicioso, sólo puede robar lo aprobado, no todo el saldo.
Comparación con MercadoPago: en MP, le das tu tarjeta una vez y MP puede cobrar lo que quiera (subscripciones, etc.). Acá, cada cobro requiere un
approveprevio del usuario. Más fricción, más seguridad.
Parte 3 — OpenZeppelin: no reinventes la rueda
OpenZeppelin es la librería estándar de contratos en Solidity. Su código está auditado, testeado y battle-tested en miles de protocolos. Nunca escribimos un ERC-20 desde cero — usamos el de OZ.
forge install OpenZeppelin/openzeppelin-contracts --no-commitEsto agrega lib/openzeppelin-contracts con todos los
standards listos: ERC-20, ERC-721, ERC-1155, AccessControl, Ownable,
ReentrancyGuard, etc.
En remappings.txt:
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
Parte 4 — El contrato
PaymentGateway
Abrí src/PaymentGateway.sol (lo creamos ahora):
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/**
* @title PaymentGateway
* @notice Recibe pagos en USDC y emite eventos para el backend.
* @dev Cada proyecto extiende esto sobrescribiendo `_onPaid`.
*/
contract PaymentGateway is ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable usdc;
address public immutable treasury;
event Paid(address indexed payer, uint256 amount, bytes32 indexed action);
error AmountZero();
error TreasuryZero();
constructor(IERC20 _usdc, address _treasury) {
if (_treasury == address(0)) revert TreasuryZero();
usdc = _usdc;
treasury = _treasury;
}
/**
* @notice Recibe un pago de `amount` USDC del caller. Requiere approve previo.
* @param amount Cantidad en USDC (con sus 6 decimales: 50 USDC = 50_000_000).
* @param action Identificador opaco de qué se está pagando ("ticket-123", "subscription", etc.).
*/
function pay(uint256 amount, bytes32 action) external nonReentrant {
if (amount == 0) revert AmountZero();
// 1. Mover los USDC del usuario al treasury.
usdc.safeTransferFrom(msg.sender, treasury, amount);
// 2. Emitir evento (para que el backend lo consuma).
emit Paid(msg.sender, amount, action);
// 3. Hook para que las subclases ejecuten lógica extra (mint NFT, registrar shares, etc.).
_onPaid(msg.sender, amount, action);
}
/// @dev Override en subclases. Por defecto no hace nada.
function _onPaid(address payer, uint256 amount, bytes32 action) internal virtual {}
}
Por qué cada decisión
| Línea | Por qué |
|---|---|
pragma solidity 0.8.24 |
Versión moderna con checked arithmetic por default (no overflow silencioso) |
import IERC20 |
Interface estándar para hablar con USDC |
SafeERC20 |
Wrapper que revierte si el token no respeta el standard (algunos
ERC-20 viejos como USDT no devuelven bool en transfer) |
ReentrancyGuard |
Mixin de OZ que evita el ataque de reentrancy (próximamente) |
immutable |
El valor se setea en el constructor y nunca cambia → más barato en gas (no usa storage) |
error AmountZero() |
Custom errors son más baratos en gas que
require(...) con string |
nonReentrant |
El modifier de OZ — bloquea llamadas re-entrantes a
pay |
_onPaid virtual |
Hook que las subclases sobrescriben — patrón template method |
Parte 5 — Tests con Foundry
Abrí test/PaymentGateway.t.sol. Vamos a usar un
mock de USDC (no podemos usar el USDC real de Sepolia
en tests locales).
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {PaymentGateway} from "../src/PaymentGateway.sol";
/// @dev Token ERC-20 fake con 6 decimales (igual que USDC) para tests.
contract MockUSDC is ERC20 {
constructor() ERC20("Mock USDC", "mUSDC") {
_mint(msg.sender, 1_000_000 * 10 ** 6); // 1M USDC
}
function decimals() public pure override returns (uint8) {
return 6;
}
}
contract PaymentGatewayTest is Test {
PaymentGateway internal gateway;
MockUSDC internal usdc;
address internal alice = makeAddr("alice");
address internal treasury = makeAddr("treasury");
function setUp() public {
usdc = new MockUSDC();
gateway = new PaymentGateway(usdc, treasury);
// Damos 1000 USDC a alice para que pueda pagar.
usdc.transfer(alice, 1000 * 10 ** 6);
}
function test_PayMovesUsdcToTreasury() public {
uint256 amount = 50 * 10 ** 6;
vm.startPrank(alice);
usdc.approve(address(gateway), amount);
gateway.pay(amount, bytes32("test-action"));
vm.stopPrank();
assertEq(usdc.balanceOf(treasury), amount);
assertEq(usdc.balanceOf(alice), (1000 * 10 ** 6) - amount);
}
function test_PayEmitsEvent() public {
uint256 amount = 100 * 10 ** 6;
vm.startPrank(alice);
usdc.approve(address(gateway), amount);
vm.expectEmit(true, true, false, true);
emit PaymentGateway.Paid(alice, amount, bytes32("ticket-1"));
gateway.pay(amount, bytes32("ticket-1"));
vm.stopPrank();
}
function test_PayRevertsOnZeroAmount() public {
vm.startPrank(alice);
usdc.approve(address(gateway), 0);
vm.expectRevert(PaymentGateway.AmountZero.selector);
gateway.pay(0, bytes32("nada"));
}
function test_PayRevertsWithoutApprove() public {
uint256 amount = 50 * 10 ** 6;
vm.startPrank(alice);
// Sin approve previo → debería fallar
vm.expectRevert();
gateway.pay(amount, bytes32("test"));
}
function testFuzz_PayAnyValidAmount(uint96 amount) public {
vm.assume(amount > 0 && amount <= 1000 * 10 ** 6);
vm.startPrank(alice);
usdc.approve(address(gateway), amount);
gateway.pay(amount, bytes32("fuzz"));
assertEq(usdc.balanceOf(treasury), amount);
}
}
Corré:
forge test -vvEsperás 5 tests en verde, incluyendo el fuzz test que prueba con cientos de inputs aleatorios.
Parte 6 — Reentrancy: el ataque que rompió The DAO
Reentrancy es EL bug más famoso de Ethereum. En 2016 alguien drenó $60M de The DAO con esta técnica. Hoy es trivial defenderse, pero hay que entenderlo.
El ataque, conceptualmente
Imaginá un contrato vulnerable así:
// MAL — no hagas esto
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw() external {
uint256 bal = balance[msg.sender];
require(bal > 0);
// 1. Mandamos el ETH al usuario
(bool ok,) = msg.sender.call{value: bal}("");
require(ok);
// 2. Después actualizamos el saldo a 0
balance[msg.sender] = 0;
}
}
El bug: en el paso 1, transferimos antes de actualizar el
saldo. Si msg.sender es un contrato malicioso, su
receive() puede volver a llamar withdraw()
antes de que el balance[msg.sender] = 0 se ejecute.
Resultado: drena el contrato 1 ETH a la vez.
Cómo se ve un atacante
contract Attacker {
VulnerableBank public bank;
constructor(VulnerableBank _bank) payable {
bank = _bank;
bank.deposit{value: 1 ether}();
}
function attack() external {
bank.withdraw();
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // ← re-entry: vuelvo a llamar withdraw
}
}
}
Cómo se cierra
3 maneras, en orden de preferencia:
- Patrón Checks-Effects-Interactions: actualizá el
state ANTES de transferir. Pone el
balance[...] = 0ANTES delcall{value: ...}. ReentrancyGuardde OpenZeppelin (lo que usamos enPaymentGateway): un mutex que impide entradas concurrentes.safeTransferFromcon tokens estándar: ERC-20 transferFrom no permite re-entry naturalmente porque devuelve antes de llamar al callback (y los tokens decentes no tienen callbacks).
En nuestro
PaymentGateway
Aplicamos las 3 defensas de una:
- ✅ Usamos
nonReentrant(mutex de OZ). - ✅ Movemos los USDC antes de emitir evento o
ejecutar
_onPaid. Si_onPaidquiere hacer cosas peligrosas (como mintar NFTs y enviarlos apayer), el guard lo cubre. - ✅ Usamos
safeTransferFromque revierte si el token devuelvefalse(algunos ERC-20 viejos lo hacen sin revertir).
💡 Tarea para casa: armar un test que intente hacer reentrancy contra
PaymentGatewayy comprobar que falla. Pista: hagan que_onPaidhaga algo arbitrario en una subclass de prueba.
Parte 7 — Deploy del PaymentGateway a Sepolia
7.1 Direcciones de USDC en Sepolia
USDC oficial de Circle en Sepolia:
0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
Para conseguir USDC de testnet hay 2 opciones:
- Faucet de Circle: https://faucet.circle.com (login Google) → seleccionar Sepolia → te tira 10 USDC.
- Mintear desde testnet bridges (ej: Aave, Compound v3 deployments en Sepolia tienen sus propios faucets).
7.2 Variables de entorno
Tu .env:
SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
ETHERSCAN_API_KEY=tu_api_key
USDC_SEPOLIA=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
TREASURY=0xtu_address_de_metamasksource .env7.3 Script de deploy
Creá script/DeployPaymentGateway.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Script, console} from "forge-std/Script.sol";
import {PaymentGateway} from "../src/PaymentGateway.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DeployPaymentGateway is Script {
function run() external returns (PaymentGateway gateway) {
IERC20 usdc = IERC20(vm.envAddress("USDC_SEPOLIA"));
address treasury = vm.envAddress("TREASURY");
vm.startBroadcast();
gateway = new PaymentGateway(usdc, treasury);
vm.stopBroadcast();
console.log("PaymentGateway deployed at:", address(gateway));
}
}
Deployar:
forge script script/DeployPaymentGateway.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--account dev \
--broadcastTe devuelve la address del PaymentGateway. Guardala:
export GATEWAY=0xtu_payment_gateway7.4 Verificar en Etherscan
forge verify-contract $GATEWAY \
src/PaymentGateway.sol:PaymentGateway \
--chain sepolia \
--constructor-args $(cast abi-encode "constructor(address,address)" $USDC_SEPOLIA $TREASURY) \
--etherscan-api-key $ETHERSCAN_API_KEY \
--watchAndá a
https://sepolia.etherscan.io/address/$GATEWAY#code — vas a
ver el source verificado y todas las funciones públicas.
Parte 8 — Probar el flow completo
Ahora hacemos el flujo end-to-end desde la terminal.
8.1 Aprobar USDC al PaymentGateway
# 50 USDC con 6 decimales = 50_000_000
cast send $USDC_SEPOLIA "approve(address,uint256)" $GATEWAY 50000000 \
--rpc-url $SEPOLIA_RPC_URL \
--account devPone allowance del PaymentGateway en 50 USDC contra tu wallet.
8.2 Verificar el allowance
cast call $USDC_SEPOLIA "allowance(address,address)(uint256)" \
$(cast wallet address --account dev) $GATEWAY \
--rpc-url $SEPOLIA_RPC_URL
# 500000008.3 Llamar pay()
cast send $GATEWAY "pay(uint256,bytes32)" 50000000 0x$(echo -n "primera-prueba" | xxd -p) \
--rpc-url $SEPOLIA_RPC_URL \
--account devTarda ~15 segundos. El receipt incluye el evento
Paid(payer=tu_address, amount=50000000, action=primera-prueba).
8.4 Verificar en Etherscan
Abrí https://sepolia.etherscan.io/address/$GATEWAY → tab
“Events”:
- Vas a ver
Paid(...)con tu address y los parámetros. - En la pestaña “Read Contract” podés ver
usdc()ytreasury()configurados correctamente. - En “Internal Txns” vas a ver el
transferFromque el PaymentGateway hizo internamente.
8.5 Confirmar saldo del treasury
cast call $USDC_SEPOLIA "balanceOf(address)(uint256)" $TREASURY \
--rpc-url $SEPOLIA_RPC_URL
# 50000000 (= 50 USDC)✅ Cobraste tu primer pago en blockchain. Esa misma operación, hecha desde un frontend con MetaMask, es lo que vamos a armar en clase 3.
Parte 9 — Tu propio ERC-20 (token del proyecto)
Hasta acá usaron el USDC oficial de Circle como medio de pago. Pero
su proyecto puede emitir su propio token ERC-20 para
representar la “moneda interna”: $VBK para VibeCheck,
$DPF para DepFund, $RNW para Renovable,
$IDEA para IDEAFY. Esto les sirve para gamificación,
dividendos, o lo que sea que necesite tokenomics propia.
OpenZeppelin lo resuelve en 15 líneas. Creá
src/ProjectToken.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";
/// @title ProjectToken
/// @notice ERC-20 del equipo. Personalizar nombre/símbolo según proyecto.
contract ProjectToken is ERC20, Ownable {
constructor(string memory name_, string memory symbol_, address owner_)
ERC20(name_, symbol_)
Ownable(owner_)
{}
/// @notice Emitir tokens. Solo el owner.
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
Deploy del token (cada equipo cambia los strings):
forge create src/ProjectToken.sol:ProjectToken \
--rpc-url $SEPOLIA_RPC \
--private-key $PRIVATE_KEY \
--constructor-args "VibeCheck Token" "VBK" $YOUR_ADDRESS \
--broadcastTe devuelve la address del token. Guardala como
$VBK_ADDR (o $DPF_ADDR, etc.). La van
a necesitar en clase 4 para enchufar tokenomics (burn 1%,
dividendos USDC, staking, governance) sobre este token.
Cómo encaja con
PaymentGateway: en clase 4 vamos a sobrescribir el hook_onPaid()del gateway para que cadapay()hagamint()del token del proyecto al payer. Ahí cierra el loop: paga USDC al treasury → el contrato emite tokens del proyecto al usuario.
Cierre — qué nos llevamos
Después de esta clase tienen:
En clase 3 vamos a:
- Reemplazar
castpor una dApp Next.js + wagmi + RainbowKit. - Usuarios reales conectando MetaMask y pagando desde el browser.
- Onramp testnet propio (
TestnetOnramp.sol) que mintea USDC fake cuando “pagás con tarjeta”. - Deploy de la dApp a Vercel.
Tarea para próxima clase
La tarea va en una página aparte: Tarea de clase 2.