tutoriales.com

Asegurando la Interoperabilidad: Desarrollando Contratos Inteligentes EIP-712 en Solidity

Este tutorial te guiará a través de la implementación del estándar EIP-712 en Solidity, permitiendo la firma de datos estructurados por parte de los usuarios fuera de la cadena. Descubre cómo mejorar la seguridad, la usabilidad y la interoperabilidad de tus contratos inteligentes mediante un sistema de firmas robusto y legible.

Intermedio18 min de lectura17 views
Reportar error

🚀 Introducción al EIP-712: Firmas Legibles y Seguras

En el mundo de Ethereum, las firmas digitales son fundamentales para autorizar transacciones y validar identidades. Tradicionalmente, la firma de mensajes arbitrarios con eth_sign o personal_sign presentaba un desafío significativo: la ilegibilidad. Los usuarios a menudo firmaban hashes crípticos de 32 bytes sin comprender completamente lo que estaban autorizando, lo que generaba riesgos de seguridad y una mala experiencia de usuario.

Aquí es donde entra en juego el EIP-712: Firmas de Datos Estructurados para Aplicaciones Descentralizadas. Este estándar revolucionario, propuesto por Vitalik Buterin, reinick, y otros, define un formato para firmar datos estructurados de manera legible y verificable. Permite que las aplicaciones muestren al usuario una representación clara y comprensible de los datos que está a punto de firmar, mitigando ataques de phishing y mejorando la confianza.

¿Por qué EIP-712 es Crucial?

La adopción de EIP-712 trae consigo múltiples beneficios esenciales para el desarrollo de DApps modernas:

  • Seguridad Mejorada: Los usuarios pueden revisar una representación clara y significativa de los datos antes de firmar, reduciendo drásticamente el riesgo de firmar transacciones maliciosas o inesperadas.
  • Experiencia de Usuario Superior (UX): Las carteras de criptomonedas (como MetaMask) pueden renderizar los datos estructurados en un formato amigable, eliminando la necesidad de que los usuarios interpreten hashes hexadecimales.
  • Interoperabilidad: Al definir un estándar común para la firma de datos estructurados, EIP-712 facilita la comunicación y la interoperabilidad entre diferentes DApps y servicios fuera de la cadena.
  • Meta-transacciones y Relayers: EIP-712 es un pilar fundamental para las meta-transacciones, donde un tercero (relay) paga las tarifas de gas en nombre del usuario. El usuario firma un mensaje que el relay puede ejecutar, sin necesidad de tener ETH para gas.
  • Reducción de Gas: A menudo, EIP-712 se usa para firmar acciones fuera de la cadena que luego son procesadas en un contrato, lo que puede reducir significativamente el uso de gas en la blockchain al evitar la ejecución de lógica compleja dentro de la cadena.
💡 Consejo: Piensa en EIP-712 como una forma de dar a tus usuarios la capacidad de 'firmar un formulario' digitalmente, donde todos los campos están claramente etiquetados y son comprensibles, en lugar de firmar un 'documento en blanco'.

🛠️ Conceptos Fundamentales de EIP-712

Antes de sumergirnos en el código, es vital entender los componentes clave que conforman una firma EIP-712.

1. Dominio de Firma (EIP-712 Domain Separator)

El Domain Separator es un hash único que previene ataques de replay entre diferentes DApps o incluso entre diferentes versiones de un mismo contrato. Actúa como un prefijo para todos los hashes de mensajes firmados, asegurando que una firma generada para una DApp específica no pueda ser usada en otra.

Está compuesto por varios campos, que se concatenan y se hashean para formar el separador de dominio. Los campos más comunes son:

  • name: El nombre legible del DApp o del protocolo (ej., "Mi DApp Fantástica").
  • version: La versión actual de la DApp (ej., "1").
  • chainId: El ID de la red Ethereum (ej., 1 para Mainnet, 5 para Goerli). Esto evita ataques de replay entre cadenas.
  • verifyingContract: La dirección del contrato inteligente que verificará la firma. Esto evita que una firma sea válida para un contrato diferente.
  • salt (opcional): Un valor aleatorio para asegurar la unicidad del separador de dominio si name, version, chainId, y verifyingContract no son suficientes.

2. Estructura del Mensaje

El mensaje que se va a firmar se define como una estructura de datos, similar a una struct en Solidity. Cada campo de esta estructura tiene un nombre y un tipo. Esta definición de tipos es crucial para que las carteras puedan interpretar y mostrar el mensaje correctamente.

Por ejemplo, si un usuario va a autorizar la transferencia de un token, el mensaje podría tener campos como spender, amount, y nonce.

3. Hash del Tipo (Type Hash)

Cada tipo de estructura definido (struct) tiene un type hash único. Este hash se genera tomando el nombre del tipo, seguido de los nombres y tipos de todos sus miembros, concatenados y luego hasheados con keccak256.

keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")

4. Hash de la Estructura (Struct Hash)

Una vez que se tiene el type hash de la estructura y los valores de sus campos, se calcula el struct hash. Esto implica codificar los valores de los campos de la estructura de una manera específica (utilizando abi.encode o abi.encodePacked dependiendo del contexto y la anidación de tipos) y luego hashear el resultado junto con el type hash.

5. El Hash Final (EIP-712 Typed Data Hash)

Finalmente, para obtener el hash que será firmado por el usuario, se combinan el Domain Separator y el Struct Hash de la siguiente manera:

keccak256("\x19\x01" || Domain Separator || Struct Hash)

El prefijo "\x19\x01" es un byte constante y una versión que protege contra colisiones con otros tipos de mensajes de Ethereum.

Datos Estructurados Definición de Tipos Valores de Campos Type Hash Struct Hash Parámetros Dominio name, version chainId verifyingContract Domain Separator \x19\x01 Hash Final EIP-712

✍️ Implementando EIP-712 en Solidity

Ahora, vamos a ver cómo implementar un contrato que pueda verificar firmas EIP-712. Para ello, crearemos un contrato Permit simplificado, similar al que se encuentra en muchos tokens ERC-20, que permite a un usuario autorizar a un tercero (spender) a gastar sus tokens sin necesidad de una transacción directa en la cadena.

1. Estructura Básica del Contrato

Necesitamos un contrato que extienda EIP712 de OpenZeppelin, que nos proporciona las utilidades necesarias para calcular el Domain Separator y otras funciones auxiliares.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract MyTokenPermit is EIP712 {
    using ECDSA for bytes32;

    string public constant NAME = "MyToken";
    string public constant VERSION = "1";

    // Mapping para guardar los nonces para cada dirección
    mapping(address => uint256) public nonces;

    // Constructor: Inicializa el EIP712 con el nombre y la versión del dominio.
    constructor() EIP712(NAME, VERSION) {
        // Inicializamos el contrato que verifica las firmas EIP712
        // Los otros parámetros del dominio (chainId) se extraen automáticamente de la cadena
    }

    // Definición de la estructura PermitTypehash
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
    bytes32 public constant PERMIT_TYPEHASH = keccak256(
        "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
    );

    /**
     * @dev Función para verificar y procesar una firma de tipo 'permit'.
     *      Permite a un 'owner' firmar una autorización para que un 'spender' gaste 'value' tokens.
     * @param owner La dirección del propietario que firma.
     * @param spender La dirección del gastador autorizado.
     * @param value La cantidad de tokens que se autoriza a gastar.
     * @param deadline El timestamp en el que la firma expira.
     * @param v El componente 'v' de la firma ECDSA.
     * @param r El componente 'r' de la firma ECDSA.
     * @param s El componente 's' de la firma ECDSA.
     */
    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public {
        require(owner != address(0), "Permit: owner is the zero address");
        require(block.timestamp <= deadline, "Permit: expired deadline");

        // 1. Calcular el hash de la estructura 'Permit'
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPEHASH,
                owner,
                spender,
                value,
                nonces[owner]++,
                deadline
            )
        );

        // 2. Calcular el hash final EIP-712 (Typed Data Hash)
        bytes32 digest = _hashTypedDataV4(structHash);

        // 3. Recuperar la dirección del firmante de la firma
        address signer = digest.recover(v, r, s);

        // 4. Verificar que la dirección recuperada es la del propietario esperado
        require(signer == owner, "Permit: invalid signature");

        // Aquí iría la lógica para procesar la autorización, por ejemplo, actualizar un mapping de 'allowance'
        // emit Approval(owner, spender, value);
    }

    // Función auxiliar para obtener el hash final EIP-712 para ser firmado off-chain
    function getTypedDataHash(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline
    ) public view returns (bytes32) {
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPEHASH,
                owner,
                spender,
                value,
                nonces[owner],
                deadline
            )
        );
        return _hashTypedDataV4(structHash);
    }
}
🔥 Importante: Este contrato es un ejemplo simplificado. En un contrato de token ERC-20 real, la función `permit` también actualizaría el `allowance` del `owner` al `spender` para el `value` especificado.

2. Explicación Detallada del Código

Vamos a desglosar las partes clave de la implementación:

  • imports: Necesitamos ECDSA para la recuperación del firmante (recover) y EIP712 para la gestión del dominio de firma y el cálculo del hash final.
  • NAME y VERSION: Estas constantes definen el nombre y la versión de nuestro dominio EIP-712. Son públicos para que las DApps puedan leerlos y formar el dominio de firma correctamente.
  • nonces mapping: Un nonce (número usado una sola vez) es crucial para evitar ataques de replay. Cada vez que un owner firma un permit, su nonce se incrementa. Si alguien intenta usar la misma firma dos veces, el nonce ya no coincidirá, y la verificación fallará.
  • Constructor EIP712(NAME, VERSION): El constructor de EIP712 de OpenZeppelin inicializa el Domain Separator para el contrato. Internamente, usa block.chainid y address(this) para los otros componentes del dominio.
  • PERMIT_TYPEHASH: Esta constante almacena el type hash para la estructura Permit. Es un keccak256 del string que describe la estructura de datos que se va a firmar. Es una buena práctica declarar estas constantes para los tipos de datos estructurados que usarás.
  • permit function: Esta es la función principal que un relay o el spender llamaría para ejecutar la autorización después de que el owner haya firmado el mensaje off-chain.
    • require: Se realizan validaciones básicas como que el owner no sea la dirección cero y que el deadline no haya expirado.
    • structHash: Se calcula el hash de la estructura Permit utilizando abi.encode con PERMIT_TYPEHASH como primer argumento, seguido de todos los valores de los campos de la estructura. Es fundamental que el orden de los campos coincida con la definición del PERMIT_TYPEHASH.
    • nonces[owner]++: Aquí se incrementa el nonce del propietario después de usarlo para calcular el structHash. Esto asegura que la misma firma no pueda ser utilizada de nuevo.
    • _hashTypedDataV4(structHash): Esta función interna de EIP712 de OpenZeppelin combina el Domain Separator con el structHash y el prefijo \x19\x01 para obtener el hash final que fue firmado por el usuario.
    • digest.recover(v, r, s): Utiliza la función recover de ECDSA para obtener la dirección pública que firmó el digest (el hash EIP-712). Esta es la magia de la verificación de firmas.
    • require(signer == owner): Una verificación crítica para asegurar que la firma fue hecha por el owner esperado.
  • getTypedDataHash function: Esta es una función view auxiliar que las DApps pueden usar off-chain para pre-calcular el hash que deben pedir al usuario que firme. Es útil para depuración y para asegurar que la DApp y el contrato estén de acuerdo en cómo se construye el mensaje a firmar.

3. Integración con el Frontend (JavaScript/TypeScript)

La parte del frontend es crucial para la experiencia EIP-712. Aquí es donde se construye el objeto de datos estructurados legible para el usuario y se envía a la cartera para su firma.

Necesitarás una librería como ethers.js o web3.js para interactuar con la cartera del usuario.

// Ejemplo simplificado con ethers.js
import { ethers } from "ethers";

// Suponiendo que tienes un proveedor y un signer (cuenta del usuario)
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const contractAddress = "0x..."; // Dirección de tu contrato MyTokenPermit
const contractABI = [
    "function nonces(address owner) view returns (uint256)"
]; // Solo necesitamos el ABI de la función nonces por ahora

const myTokenContract = new ethers.Contract(contractAddress, contractABI, provider);

async function signPermitMessage(owner, spender, value, deadline) {
    // Obtener el nonce actual del propietario
    const nonce = await myTokenContract.nonces(owner);

    const domain = {
        name: "MyToken",
        version: "1",
        chainId: (await provider.getNetwork()).chainId,
        verifyingContract: contractAddress,
    };

    // Los tipos para el objeto de datos estructurados
    const types = {
        Permit: [
            { name: "owner", type: "address" },
            { name: "spender", type: "address" },
            { name: "value", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" },
        ],
    };

    // Los valores para el objeto de datos estructurados
    const message = {
        owner: owner,
        spender: spender,
        value: value,
        nonce: nonce.toString(), // Convertir a string para evitar problemas de BigNumber en JSON
        deadline: deadline,
    };

    try {
        // Firmar los datos estructurados utilizando _signTypedData
        // MetaMask u otras carteras mostrarán el mensaje de forma legible
        const signature = await signer._signTypedData(domain, types, message);

        // Descomponer la firma en r, s, v
        const sig = ethers.utils.splitSignature(signature);

        console.log("Signature successfully generated:", sig);
        return sig; // { r, s, v }

    } catch (error) {
        console.error("Error signing permit message:", error);
        throw error;
    }
}

// Ejemplo de uso
// const ownerAddress = "0x..."; // La dirección del usuario loggeado
// const spenderAddress = "0x..."; // La dirección a la que se le dará permiso
// const amount = ethers.utils.parseUnits("100", 18); // 100 tokens
// const deadlineTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hora a partir de ahora

// signPermitMessage(ownerAddress, spenderAddress, amount, deadlineTimestamp)
//     .then(sig => {
//         // Ahora puedes enviar sig.r, sig.s, sig.v al contrato MyTokenPermit
//         // a través de una transacción del relay o del propio spender.
//     })
//     .catch(err => console.error(err));
📌 Nota: Es vital que la `domain` y `types` pasados a `_signTypedData` coincidan *exactamente* con lo que el contrato inteligente espera, incluyendo los nombres de los campos y sus tipos. Un error aquí resultará en una firma inválida.

🛡️ Consideraciones de Seguridad

La implementación de EIP-712, si bien mejora la seguridad general, introduce nuevas áreas a considerar:

  • Ataques de Replay entre Cadenas: El chainId en el dominio de firma es fundamental para prevenir que una firma generada en una red (ej., Goerli) sea re-utilizada en otra (ej., Mainnet). Asegúrate de que tu contrato lo use correctamente.
  • Ataques de Replay entre Contratos: El verifyingContract es igual de importante para evitar que una firma para un contrato sea válida para otro contrato con la misma estructura de dominio/mensaje.
  • Gestión de Nonces: Como se mencionó, el nonce es vital para prevenir que una misma firma se use múltiples veces. Asegúrate de que el contrato incremente el nonce después de procesar una firma válida. Un nonce por cada owner es la práctica estándar.
  • Fechas Límite (deadline): Incluir una fecha límite en el mensaje es una buena práctica para limitar la ventana de tiempo durante la cual una firma es válida, mitigando el riesgo de que firmas antiguas sean utilizadas para propósitos no deseados.
  • _hashTypedDataV4 vs _hashTypedDataV3: OpenZeppelin proporciona ambas. _hashTypedDataV4 es la versión más reciente y robusta, y se recomienda su uso. V3 tiene algunas limitaciones con tipos anidados o arrays.
  • Verificación Off-Chain: Si estás construyendo sistemas que verifican firmas EIP-712 off-chain (por ejemplo, un backend de un relay), asegúrate de que tu lógica off-chain replique exactamente el proceso de hashing y verificación del contrato inteligente para evitar inconsistencias y vulnerabilidades.

📝 Escenarios de Uso y Casos Prácticos

EIP-712 es una herramienta poderosa que desbloquea una variedad de casos de uso innovadores:

  • Meta-transacciones: Como se explicó, los usuarios pueden firmar una intención de transacción, y un relay paga el gas para ejecutarla en la cadena. Esto es fundamental para la adopción masiva, ya que los nuevos usuarios no necesitan tener ETH para empezar a interactuar con una DApp.
  • Tokens ERC-20 permit: Muchos tokens ERC-20 (como USDC, DAI) implementan la función permit. Esto permite a los usuarios aprobar a un spender (ej., un exchange descentralizado) sin una transacción previa de approve. Reduciendo una transacción de la cadena a una firma off-chain y una llamada a permit.
  • Liquidación de Préstamos Descentralizados: En protocolos de préstamos, un liquidador podría necesitar una firma del prestatario para iniciar un proceso de liquidación bajo ciertas condiciones. EIP-712 puede asegurar que el prestatario entienda exactamente lo que está autorizando.
  • Votación Off-Chain: Para DApps con sistemas de gobernanza, los usuarios pueden firmar sus votos off-chain. Luego, estos votos firmados se agregan y se envían a la cadena en una sola transacción, ahorrando gas y escalando el proceso de votación.
  • Interacciones con Capas 2 (L2s): En soluciones de escalado, EIP-712 puede utilizarse para firmar transacciones off-chain que luego son agregadas y enviadas a la L1, o para autorizar movimientos de fondos entre cuentas en la L2.
💡 Consejo: Cada vez que pienses en una acción que un usuario realiza fuera de la cadena que luego necesita ser validada en la cadena, considera EIP-712. Puede simplificar la UX y reducir los costos de gas.

🆚 Comparativa: EIP-712 vs. Otros Métodos de Firma

Es útil comprender cómo EIP-712 se compara con los métodos de firma más antiguos en Ethereum.

Característicaeth_sign / personal_sign (antiguo)EIP-191 (Prefix Signed Message)EIP-712 (Typed Data)
------------
LegibilidadBaja (firma de hash crudo)Baja (firma de hash de mensaje)Alta (datos estructurados legibles)
Prevención de PhishingBaja (fácil firmar ciegamente)Baja (fácil firmar ciegamente)Alta (contexto claro al usuario)
------------
Replay ProtectionBajo (depende de la implementación)Bajo (depende de la implementación)Integrado (Domain Separator)
InteroperabilidadBaja (formato no estandarizado)Media (prefijo estándar)Alta (formato de datos estructurados)
------------
Complejidad de Impl.Baja (solo firmar bytes)Baja (solo firmar bytes)Media (requiere definición de tipos)
Casos de Uso PrincipalesMensajes arbitrarios, autenticaciónMensajes arbitrarios, autenticaciónMeta-transacciones, permit, gobernanza
⚠️ Advertencia: Evita `eth_sign` en la medida de lo posible para nuevas implementaciones. `personal_sign` es ligeramente mejor por añadir el prefijo EIP-191, pero EIP-712 es el estándar de oro para la firma de datos estructurados.

📚 Recursos Adicionales

Para profundizar en el EIP-712 y sus implementaciones, aquí tienes algunos recursos útiles:


✅ Conclusión

El EIP-712 es un avance significativo en el ecosistema Ethereum, elevando el nivel de seguridad y usabilidad para las DApps que requieren firmas off-chain. Al permitir que los usuarios firmen datos estructurados de una manera clara y comprensible, se reduce el riesgo de ataques maliciosos y se mejora la experiencia general del usuario. La implementación de este estándar, aunque requiere una comprensión de sus componentes, es facilitada enormemente por librerías como OpenZeppelin y ethers.js, haciendo que sea una herramienta accesible y potente para cualquier desarrollador de contratos inteligentes.

Al integrar EIP-712 en tus proyectos, no solo estás construyendo aplicaciones más seguras y amigables, sino que también estás contribuyendo a un ecosistema más robusto e interoperable. ¡Empieza a explorar el poder de las firmas de datos estructurados hoy mismo!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!