approve + transferFrom: cómo funciona una pasarela de pago con ERC-20 y un NFT como recibo

Cátedra de la Diplomatura3 min de lectura

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:

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:

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.

Otras notas

Ver todas las notas del blog