EIP-191 frente a EIP-712 es la elección entre los dos formatos de firma de Ethereum para datos que no son una transacción. EIP-191 (2016) define un prefijo 0x19 con el que nunca puede empezar la codificación de una transacción válida, seguido de un byte de versión que selecciona el formato; la versión 0x45 es personal_sign, que se usa para texto legible por personas. EIP-712 (2017) es la versión 0x01: calcula el hash de datos tipados y estructurados junto con un separador de dominio que nombra la aplicación, la cadena y el contrato verificador. Por tanto, EIP-712 es un formato concreto de EIP-191, no un competidor del estándar en su conjunto.
De un vistazo
EIP-191 personal_sign (versión 0x45) | Datos tipados EIP-712 (versión 0x01) | |
|---|---|---|
| Estado | Final, creado el 2016-01-20 | Final, creado el 2017-09-12 |
| Método RPC de la billetera | personal_sign | eth_signTypedData_v4 |
| Bytes que se pasan a Keccak-256 | 0x19 "Ethereum Signed Message:\n" longitud mensaje | 0x19 0x01 domainSeparator hashStruct(message) |
| Qué muestra la billetera | El texto, o hex en bruto si el mensaje es binario | Nombres y valores de los campos, más el dominio (nombre, cadena, contrato) |
| Protección contra repetición | Ninguna integrada; el texto del mensaje debe incluir el nonce, el dominio y la cadena | Separador de dominio con name, version, chainId, verifyingContract, salt |
| Verificación en el contrato | Reconstruir el mensaje con prefijo y después ecrecover | Recalcular los hashes de tipo y los hashes de estructura y después ecrecover |
| Usos típicos | Sign-In with Ethereum (EIP-4361), prueba de propiedad de una dirección | Permit (EIP-2612), Permit2, transacciones de Safe, órdenes en exchanges, votaciones de gobernanza |
| Firma | r, s, v de ECDSA sobre secp256k1 (65 bytes) | Igual |
| Principales trampas | Hashes en hex firmados a ciegas; texto libre ambiguo | Aprobaciones de apariencia inofensiva; dispositivos que solo muestran un hash |
Qué entra en el hash de cada uno
La versión 0x45 de EIP-191 antepone "\x19Ethereum Signed Message:\n" y la longitud del mensaje en ASCII decimal, y después calcula el hash con Keccak-256. El prefijo garantiza que los bytes firmados no pueden ser una transacción codificada en RLP, así que un sitio no puede engañar a una billetera para que firme una transferencia haciéndola pasar por un mensaje. No hace nada más: que la firma signifique «iniciar sesión en example.com» o «aprobar un gasto ilimitado» depende por completo de cómo interprete el texto el receptor.
EIP-712 codifica cada estructura como el hash de su cadena de tipo seguido de palabras de 32 bytes para sus campos: de las cadenas y los bytes se calcula el hash, las direcciones y los enteros se rellenan por la izquierda y las estructuras anidadas se sustituyen por sus propios hashes. El dominio se codifica del mismo modo, como una estructura EIP712Domain. El resumen final es keccak256(0x19 0x01 domainSeparator hashStruct(message)).
El script siguiente calcula ambos con @noble/curves 2.3.0 y @noble/hashes 2.3.0 sobre Node.js 26, usando el mensaje «Ether Mail» y la clave de firma del Example.js de referencia de EIP-712. Esa clave, keccak256("cow"), está publicada en la especificación como vector de prueba: cualquiera puede derivarla, así que su dirección nunca debe contener fondos.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { keccak_256 } from "@noble/hashes/sha3.js";
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
const hex = (b) => "0x" + bytesToHex(b);
// Test key published in the EIP-712 reference example (assets/eip-712/Example.js): keccak256("cow").
// It is public, so never send funds to its address.
const secretKey = keccak_256(utf8ToBytes("cow"));
const signer = hex(keccak_256(secp256k1.getPublicKey(secretKey, false).subarray(1)).subarray(12));
const sign = (digest) => {
const sig = secp256k1.sign(digest, secretKey, { prehash: false, format: "recovered" });
return { v: sig[0] + 27, r: hex(sig.subarray(1, 33)), s: hex(sig.subarray(33)) };
};
// EIP-191 version 0x45: personal_sign
const text = utf8ToBytes("hello");
const personalDigest = keccak_256(concatBytes(utf8ToBytes(`\x19Ethereum Signed Message:\n${text.length}`), text));
console.log("EIP-191 personal_sign digest:", hex(personalDigest));
// EIP-712: typed structured data (the "Ether Mail" example from the EIP)
const types = {
EIP712Domain: [["name", "string"], ["version", "string"], ["chainId", "uint256"], ["verifyingContract", "address"]],
Person: [["name", "string"], ["wallet", "address"]],
Mail: [["from", "Person"], ["to", "Person"], ["contents", "string"]],
};
const encodeType = (primary) => {
const deps = new Set();
const walk = (t) => types[t].forEach(([, ft]) => { if (types[ft] && !deps.has(ft)) { deps.add(ft); walk(ft); } });
walk(primary);
return [primary, ...[...deps].sort()].map((t) => `${t}(${types[t].map(([n, ft]) => `${ft} ${n}`).join(",")})`).join("");
};
const encodeValue = (ft, v) =>
types[ft] ? hashStruct(ft, v)
: ft === "string" ? keccak_256(utf8ToBytes(v))
: ft === "address" ? concatBytes(new Uint8Array(12), hexToBytes(v.slice(2)))
: hexToBytes(BigInt(v).toString(16).padStart(64, "0")); // uint256
const hashStruct = (t, v) =>
keccak_256(concatBytes(keccak_256(utf8ToBytes(encodeType(t))), ...types[t].map(([n, ft]) => encodeValue(ft, v[n]))));
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const mail = {
from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
contents: "Hello, Bob!",
};
const typedDigest = (d) => keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), hashStruct("EIP712Domain", d), hashStruct("Mail", mail)));
console.log("encodeType(Mail):", encodeType("Mail"));
console.log("domainSeparator:", hex(hashStruct("EIP712Domain", domain)));
console.log("hashStruct(Mail):", hex(hashStruct("Mail", mail)));
console.log("EIP-712 digest:", hex(typedDigest(domain)));
console.log("signer:", signer, sign(typedDigest(domain)));
console.log("same mail, chainId 10:", hex(typedDigest({ ...domain, chainId: 10 })));
EIP-191 personal_sign digest: 0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750
encodeType(Mail): Mail(Person from,Person to,string contents)Person(string name,address wallet)
domainSeparator: 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
hashStruct(Mail): 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
EIP-712 digest: 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer: 0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826 {
v: 28,
r: '0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d',
s: '0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562'
}
same mail, chainId 10: 0x2752db86097e9749484b4645855d9ff3d1b6109c89ed55a9d7f9021dbc77bcf9
La cadena de tipo, el separador de dominio, el hash de la estructura, el resumen, la dirección del firmante y v, r, s son exactamente los valores que comprueba el Example.js de referencia de EIP-712, y la firma es determinista (RFC 6979), así que puedes reproducirla. Cambiar solo el chainId produce un resumen distinto, y eso es lo que impide la repetición entre cadenas. El inspector de EIP-712 muestra los mismos hashes intermedios para cualquier carga útil de datos tipados.
Qué ven realmente los usuarios
La principal ventaja de EIP-712 es la presentación: una billetera puede mostrar «spender», «value» y «deadline» en lugar de una cadena opaca. Eso solo sirve si el dispositivo puede mostrar los campos y el usuario los lee. Hoy, la mayor parte del phishing que vacía billeteras pide firmas EIP-712 completamente válidas, como un permit (EIP-2612) o una aprobación de Permit2 que concede a un spender tokens ilimitados, sin transacción y sin gas, así que el usuario no ve ninguna advertencia de transacción en cadena. En febrero de 2025, tres firmantes de Bybit aprobaron una transacción de Safe maliciosa en billeteras de hardware que solo podían mostrar su hash; la transacción sustituyó la implementación de la billetera y se robaron unos 1,5 mil millones de dólares en ETH. Recalcular de forma independiente el hash de una transacción de Safe, como hace la herramienta de hash de transacciones de Safe, es la forma en que los firmantes pueden comprobar lo que un dispositivo muestra en hexadecimal.
personal_sign tiene la debilidad opuesta: las billeteras muestran el texto fielmente, pero un sitio puede pedir a los usuarios que firmen una cadena hexadecimal de 32 bytes, que no revela nada. Por ese motivo, MetaMask desactivó por defecto en 2023 la firma de hashes en bruto sin ningún prefijo (eth_sign) y la eliminó en 2024.
Verificación de firmas
En las cuentas de propiedad externa (EOA), ambos formatos se verifican recalculando el resumen y recuperando el firmante con ecrecover (o un equivalente de una biblioteca), y comparando después la dirección; rechaza las firmas high-s para evitar la maleabilidad. Las billeteras de contrato inteligente (Safe, cuentas ERC-4337) no pueden producir firmas ECDSA propias, así que los verificadores deben llamar a ERC-1271 isValidSignature(hash, signature) y aceptar el valor mágico 0x1626ba7e; ERC-6492 extiende esto a las billeteras que todavía no se han desplegado. El verificador de firmas de Ethereum y el verificador de SIWE cubren ambos casos.
Cuándo elegir cada uno
- Inicio de sesión y prueba de propiedad de una dirección: EIP-191 con un mensaje EIP-4361 (SIWE) que incluya dominio, URI, ID de cadena, nonce y caducidad.
- Aprobaciones de tokens sin transacción: permit de EIP-712 o Permit2, con plazos cortos.
- Órdenes, votaciones, metatransacciones, atestaciones: EIP-712 con un dominio que incluya
chainIdyverifyingContract. - Transacciones multifirma: EIP-712, como hace Safe, más una verificación independiente del hash en un segundo dispositivo.
- Texto libre que una persona debe leer: EIP-191.
- Nunca:
personal_signsobre un hash, ni dominios EIP-712 sin ID de cadena.
Notas de migración
- Al migrar de
personal_signa EIP-712 una autorización fuera de la cadena: define la estructura, incluye campos de nonce y de plazo, y sigue aceptando el formato antiguo solo hasta que caduquen las firmas pendientes. - Versiona el dominio: incrementa
versioncuando cambie la semántica de los campos, para que las firmas antiguas no puedan interpretarse con reglas nuevas. - Verifica con la misma biblioteca con la que firmas, y haz pruebas con los vectores de referencia de EIP-712 de arriba.
- Admite las billeteras de contrato mediante ERC-1271 y ERC-6492 en lugar de dar por hecho
ecrecover. - Revoca y haz caducar: ofrece a los usuarios una forma de revocar los permits y usa plazos cortos; las firmas sin caducidad siguen siendo utilizables indefinidamente.