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.
🚀 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.
🛠️ 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.,1para Mainnet,5para 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 siname,version,chainId, yverifyingContractno 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.
✍️ 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);
}
}
2. Explicación Detallada del Código
Vamos a desglosar las partes clave de la implementación:
imports: NecesitamosECDSApara la recuperación del firmante (recover) yEIP712para la gestión del dominio de firma y el cálculo del hash final.NAMEyVERSION: 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.noncesmapping: Unnonce(número usado una sola vez) es crucial para evitar ataques de replay. Cada vez que unownerfirma unpermit, sunoncese incrementa. Si alguien intenta usar la misma firma dos veces, elnonceya no coincidirá, y la verificación fallará.- Constructor
EIP712(NAME, VERSION): El constructor deEIP712de OpenZeppelin inicializa elDomain Separatorpara el contrato. Internamente, usablock.chainidyaddress(this)para los otros componentes del dominio. PERMIT_TYPEHASH: Esta constante almacena eltype hashpara la estructuraPermit. Es unkeccak256del 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.permitfunction: Esta es la función principal que un relay o elspenderllamaría para ejecutar la autorización después de que elownerhaya firmado el mensaje off-chain.require: Se realizan validaciones básicas como que elownerno sea la dirección cero y que eldeadlineno haya expirado.structHash: Se calcula el hash de la estructuraPermitutilizandoabi.encodeconPERMIT_TYPEHASHcomo 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 delPERMIT_TYPEHASH.nonces[owner]++: Aquí se incrementa el nonce del propietario después de usarlo para calcular elstructHash. Esto asegura que la misma firma no pueda ser utilizada de nuevo._hashTypedDataV4(structHash): Esta función interna deEIP712de OpenZeppelin combina elDomain Separatorcon elstructHashy el prefijo\x19\x01para obtener el hash final que fue firmado por el usuario.digest.recover(v, r, s): Utiliza la funciónrecoverdeECDSApara obtener la dirección pública que firmó eldigest(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 elowneresperado.
getTypedDataHashfunction: Esta es una funciónviewauxiliar 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));
🛡️ 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
chainIden 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
verifyingContractes 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
noncees vital para prevenir que una misma firma se use múltiples veces. Asegúrate de que el contrato incremente elnoncedespués de procesar una firma válida. Unnoncepor cadaowneres 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. _hashTypedDataV4vs_hashTypedDataV3: OpenZeppelin proporciona ambas._hashTypedDataV4es 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ónpermit. Esto permite a los usuarios aprobar a unspender(ej., un exchange descentralizado) sin una transacción previa deapprove. Reduciendo una transacción de la cadena a una firma off-chain y una llamada apermit. - 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.
🆚 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ística | eth_sign / personal_sign (antiguo) | EIP-191 (Prefix Signed Message) | EIP-712 (Typed Data) |
|---|---|---|---|
| --- | --- | --- | --- |
| Legibilidad | Baja (firma de hash crudo) | Baja (firma de hash de mensaje) | Alta (datos estructurados legibles) |
| Prevención de Phishing | Baja (fácil firmar ciegamente) | Baja (fácil firmar ciegamente) | Alta (contexto claro al usuario) |
| --- | --- | --- | --- |
| Replay Protection | Bajo (depende de la implementación) | Bajo (depende de la implementación) | Integrado (Domain Separator) |
| Interoperabilidad | Baja (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 Principales | Mensajes arbitrarios, autenticación | Mensajes arbitrarios, autenticación | Meta-transacciones, permit, gobernanza |
📚 Recursos Adicionales
Para profundizar en el EIP-712 y sus implementaciones, aquí tienes algunos recursos útiles:
- EIP-712 Standard: https://eips.ethereum.org/EIPS/eip-712
- OpenZeppelin Contracts
EIP712.sol: https://docs.openzeppelin.com/contracts/4.x/api/utils#EIP712 - OpenZeppelin Contracts
ECDSA.sol: https://docs.openzeppelin.com/contracts/4.x/api/utils#ECDSA ethers.js_signTypedData: https://docs.ethers.org/v5/api/signer/#Signer-signTypedData
✅ 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
- Explorando los Estándares de Tokens en Ethereum: ERC-20, ERC-721 y ERC-1155intermediate20 min
- Navegando el Laberinto del Almacenamiento en Solidity: Entendiendo Storage, Memory y Calldata para Contratos Eficientesintermediate18 min
- Decentralized Autonomous Organizations (DAOs) en Ethereum: Creación y Gestión con Solidityintermediate25 min
- Desarrollando Contratos Inteligentes Auto-Actualizables en Solidity: Patrones de Actualización en Ethereumadvanced20 min
- Optimización de Gas en Solidity: Estrategias Avanzadas para Contratos Inteligentes Eficientesintermediate12 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!