Saltar al contenido
ecdsa.com

EIP-191 frente a EIP-712: personal_sign o datos tipados

Ambos impiden que una firma de Ethereum sirva también como transacción. EIP-191 firma un mensaje con prefijo; EIP-712 firma campos tipados vinculados a una aplicación, una cadena y un contrato.

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)
EstadoFinal, creado el 2016-01-20Final, creado el 2017-09-12
Método RPC de la billeterapersonal_signeth_signTypedData_v4
Bytes que se pasan a Keccak-2560x19 "Ethereum Signed Message:\n" longitud mensaje0x19 0x01 domainSeparator hashStruct(message)
Qué muestra la billeteraEl texto, o hex en bruto si el mensaje es binarioNombres y valores de los campos, más el dominio (nombre, cadena, contrato)
Protección contra repeticiónNinguna integrada; el texto del mensaje debe incluir el nonce, el dominio y la cadenaSeparador de dominio con name, version, chainId, verifyingContract, salt
Verificación en el contratoReconstruir el mensaje con prefijo y después ecrecoverRecalcular los hashes de tipo y los hashes de estructura y después ecrecover
Usos típicosSign-In with Ethereum (EIP-4361), prueba de propiedad de una direcciónPermit (EIP-2612), Permit2, transacciones de Safe, órdenes en exchanges, votaciones de gobernanza
Firmar, s, v de ECDSA sobre secp256k1 (65 bytes)Igual
Principales trampasHashes en hex firmados a ciegas; texto libre ambiguoAprobaciones 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.

Resúmenes EIP-191 y EIP-712, comprobados con la referencia de EIP-712
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 })));
Salida
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 chainId y verifyingContract.
  • 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_sign sobre un hash, ni dominios EIP-712 sin ID de cadena.

Notas de migración

  1. Al migrar de personal_sign a 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.
  2. Versiona el dominio: incrementa version cuando cambie la semántica de los campos, para que las firmas antiguas no puedan interpretarse con reglas nuevas.
  3. Verifica con la misma biblioteca con la que firmas, y haz pruebas con los vectores de referencia de EIP-712 de arriba.
  4. Admite las billeteras de contrato mediante ERC-1271 y ERC-6492 en lugar de dar por hecho ecrecover.
  5. 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.

Referencias

Todas las comparaciones