Pagar con un token ERC-20 no es mandar plata a un contrato: es darle permiso a un contrato para que la cobre. Ese permiso explica el patrón de dos pasos que usan casi todas las pasarelas Web3.
Por qué hacen falta dos transacciones
El ETH se envía junto con la llamada a un contrato. Un token ERC-20 no: es un registro de saldos que vive dentro del contrato del token, y un contrato externo no puede tocarlo por su cuenta. El estándar EIP-20 resuelve esto con un mecanismo de autorización:
approve(spender, valor)autoriza aspendera retirar de tu cuenta, varias veces, hastavalor. Si lo volvés a llamar, el nuevo valor reemplaza al anterior.allowance(owner, spender)devuelve cuánto le queda por retirar.transferFrom(from, to, valor)mueve tokens defromatoy es la vía que usa un contrato para cobrar en tu nombre. Debe fallar sifromno lo autorizó.
Entonces, una compra son dos firmas: primero approve sobre el token, después pagar sobre la pasarela. Es lo que ves en el Nivel 4 del juego.
La pasarela mínima
El siguiente contrato cobra en un token ERC-20 y emite un NFT como recibo. Usa OpenZeppelin 5.x, y lo compilamos y probamos con Foundry antes de publicar esta nota:
// 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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
/// NFT que hace de recibo. Solo la pasarela puede emitirlo.
contract Recibo is ERC721 {
address public immutable pasarela;
uint256 public proximoId;
error SoloPasarela();
constructor(address _pasarela) ERC721("Recibo de pago", "RCBO") {
pasarela = _pasarela;
}
function emitir(address a) external returns (uint256 id) {
if (msg.sender != pasarela) revert SoloPasarela();
id = proximoId++;
_safeMint(a, id);
}
}
contract Pasarela {
using SafeERC20 for IERC20;
IERC20 public immutable token;
address public immutable comercio;
Recibo public immutable recibo;
event Pago(address indexed pagador, bytes32 indexed producto, uint256 monto, uint256 reciboId);
constructor(IERC20 _token, address _comercio) {
token = _token;
comercio = _comercio;
recibo = new Recibo(address(this));
}
/// El pagador tiene que haber hecho antes token.approve(address(this), monto).
function pagar(bytes32 producto, uint256 monto) external returns (uint256 reciboId) {
// La pasarela mueve los tokens del pagador gracias al allowance.
token.safeTransferFrom(msg.sender, comercio, monto);
// El recibo se emite al final, cuando el cobro ya se hizo.
reciboId = recibo.emitir(msg.sender);
emit Pago(msg.sender, producto, monto, reciboId);
}
}
Qué decisiones hay detrás
SafeERC20. Algunos tokens no cumplen el estándar al pie de la letra, por ejemplo no devuelven el booleano esperado. safeTransferFrom maneja esos casos y revierte si la transferencia falla, en lugar de seguir como si hubiera funcionado.
El recibo es un NFT. Un ERC-721 es único por identificador y queda en la wallet del pagador, así que es una prueba verificable de que el pago ocurrió. El evento Pago permite que un frontend o un indexador reconstruya el historial sin leer todo el estado.
Solo la pasarela emite. Si cualquiera pudiera llamar a emitir, los recibos no probarían nada. El control es una comparación contra la dirección inmutable de la pasarela.
_safeMint y el orden de las operaciones. Si el destinatario es un contrato, _safeMint invoca onERC721Received en él, lo cual entrega el control a código ajeno. Por eso el recibo se emite después del cobro. Es el mismo criterio de la nota sobre reentrancy: primero los efectos propios, al final las llamadas externas. En nuestras pruebas, una cuenta que es un contrato sin esa función no puede recibir el recibo y la transacción entera revierte.
El permiso también es un riesgo
Un approve es una autorización con consecuencias. Un permiso mayor al necesario, o dado a un contrato con fallas, expone tus fondos. Algunas buenas prácticas:
- Autorizá el monto de la compra y no más. En nuestro contrato, el allowance baja a cero tras el pago, y así lo verifica nuestra prueba.
- El estándar recomienda que las interfaces pongan el allowance en 0 antes de cambiarlo por otro valor distinto, para evitar una condición de carrera conocida.
- Revisá periódicamente a qué contratos les diste permiso.
Fuentes
Verificadas el 27/10/2026. Los ejemplos de código se compilaron y probaron con Foundry (Solidity 0.8.33, OpenZeppelin Contracts 5.7.0) y Python 3.