Saltar al contenido
ecdsa.com
← Todos los artículos

EIP-712: qué firmas realmente y cómo leerlo

Una firma de datos tipados no cuesta gas ni aparece en ningún explorador de bloques hasta que alguien la usa, y aun así puede entregar todos los tokens de un tipo durante años. Aquí tienes qué son esos bytes, qué conceden los formatos de aprobación habituales y un programa breve que convierte una solicitud de firma en una explicación clara antes de que nadie firme.

Respuesta breve. EIP-712 firma un hash de datos estructurados: keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). El dominio (nombre, versión, ID de cadena, contrato verificador) indica para qué contrato y en qué cadena vale la firma; el mensaje es una estructura tipada. Antes de firmar, lee cuatro cosas: verifyingContract y chainId (¿es el contrato que crees, en la cadena en la que estás?), primaryType (¿una aprobación, una orden, un inicio de sesión?), el spender o el destinatario, y los importes y los plazos. Un mensaje ERC-2612 Permit o Permit2 de Uniswap con el importe máximo y un plazo lejano es una aprobación ilimitada que cualquiera que tenga la firma puede activar más adelante. Si tu billetera de hardware muestra solo un hash del dominio y un hash del mensaje, vuelve a calcular ambos en otro dispositivo y compáralos.

Qué son esos bytes

EIP-712 (estado Final) define tres piezas:

  • encodeType: un tipo de estructura como cadena de texto, Mail(Person from,Person to,string contents), seguido de todos los tipos de estructura a los que hace referencia, ordenados por nombre: …Person(string name,address wallet). Su keccak256 es el hash del tipo (type hash).
  • hashStruct(s) = keccak256(typeHash ‖ encodeData(s)), donde cada miembro se codifica en 32 bytes: los enteros y las direcciones como palabras ABI, string y bytes como el keccak256 de su contenido, las estructuras anidadas como su propio hashStruct y los arrays como el keccak256 de sus codificaciones concatenadas.
  • domainSeparator = hashStruct(EIP712Domain), calculado sobre los campos que use la aplicación de entre name, version, chainId, verifyingContract y salt, en ese orden.

El resumen firmado es keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). El prefijo 0x19 impide que llegue a ser nunca una transacción válida (RLP nunca empieza por 0x19), y el byte de versión 0x01 lo distingue del formato 0x19 "Ethereum Signed Message" de personal_sign, definido en EIP-191. La firma es una firma ECDSA secp256k1 corriente, de 65 bytes con el byte de recuperación, y de ella se recupera la dirección del firmante.

El ejemplo de la propia especificación, recalculado con viem solo a partir de datos públicos (los datos tipados y los v, r, s publicados):

spec-vector.mjs (viem 2.57.7, Node.js 24.22)
// The example from the EIP-712 specification (assets/eip-712/Example.js), checked with viem.
// Only public data is used: the typed data and the published signature (v, r, s).
import { hashDomain, hashStruct, hashTypedData, keccak256, recoverTypedDataAddress, serializeSignature, toHex } from "viem";

const types = {
  Person: [
    { name: "name", type: "string" },
    { name: "wallet", type: "address" },
  ],
  Mail: [
    { name: "from", type: "Person" },
    { name: "to", type: "Person" },
    { name: "contents", type: "string" },
  ],
};
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const message = {
  from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
  to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
  contents: "Hello, Bob!",
};

const encodeType = "Mail(Person from,Person to,string contents)Person(string name,address wallet)";
console.log(`typeHash(Mail)     ${keccak256(toHex(encodeType))}`);
console.log(`hashStruct(Mail)   ${hashStruct({ data: message, primaryType: "Mail", types })}`);
console.log(`domainSeparator    ${hashDomain({ domain, types: { EIP712Domain: [
  { name: "name", type: "string" },
  { name: "version", type: "string" },
  { name: "chainId", type: "uint256" },
  { name: "verifyingContract", type: "address" },
] } })}`);
console.log(`digest             ${hashTypedData({ domain, types, primaryType: "Mail", message })}`);

const signature = serializeSignature({
  r: "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d",
  s: "0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562",
  v: 28n,
});
console.log(`signer             ${await recoverTypedDataAddress({ domain, types, primaryType: "Mail", message, signature })}`);
Salida (node:24.22.0-bookworm-slim)
typeHash(Mail)     0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail)   0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator    0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest             0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer             0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826

Todos los valores coinciden con los esperados en el Example.js del EIP, y la firma recupera la dirección del firmante del ejemplo.

Qué protege el dominio y qué no

El dominio hace que una firma sea válida para un contrato en una cadena. El mismo mensaje firmado para la cadena 1 no se verifica en la cadena 8453, y un cambio de version en un token invalida todas las firmas pendientes. La especificación dice que una billetera «debería negarse a firmar» cuando chainId no coincide con la cadena activa; MetaMask lo hace, con el error Provided chainId "1" must match the active chainId "137".

Lo que el dominio no puede decirte es si el contrato es el auténtico. Un sitio de phishing puede pedir una firma con name: "USD Coin" y la dirección de su propio contrato; solo verifyingContract, comparado con una dirección de confianza, lo resuelve. En los tokens con ERC-2612, verifyingContract es el propio contrato del token; en Permit2, es el contrato de Permit2; en las órdenes de un marketplace, su contrato de intercambio.

Los formatos que mueven tokens

permit de ERC-2612 (ERC-2612, estado Final) es un approve mediante firma: Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). Cualquiera que tenga la firma puede enviarla antes de deadline; el token establece entonces allowance[owner][spender] = value e incrementa el nonce del propietario. El propio estándar señala que deadline «puede fijarse en uint(-1) para crear permits que, en la práctica, nunca caducan», y un value de 2^256 − 1 es el valor «ilimitado» convencional. No aparece nada en la cadena hasta que se usa la firma, así que la lista de aprobaciones de una billetera no lo muestra.

Uniswap Permit2 es un único contrato, en 0x000000000022D473030F116dDEE9F6B43aC78BA3 en todas las cadenas donde está desplegado, que guarda las aprobaciones de tokens de muchas aplicaciones. El usuario aprueba Permit2 una vez en la cadena; a partir de ahí, cada aplicación obtiene acceso mediante una firma:

  • PermitSingle / PermitBatch (AllowanceTransfer) conceden un límite de gasto: PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce), un spender y un sigDeadline para la propia firma. Un amount de 2^160 − 1 es ilimitado y las transferencias no lo reducen; un expiration de 2^48 − 1 no caduca nunca, y 0 significa «solo en este bloque».
  • PermitTransferFrom / PermitBatchTransferFrom (SignatureTransfer) autorizan una sola transferencia de hasta amount antes de deadline, con un nonce de un solo uso. Son más seguros: no queda nada pendiente.
  • Las variantes witness añaden a la misma firma una estructura de la aplicación, como una orden de UniswapX.

El dominio de Permit2 tiene name: "Permit2", chainId y verifyingContract, y no tiene version. Las cadenas de tipo anteriores proceden de PermitHash.sol, de Permit2.

Cómo leer una solicitud antes de firmarla

Este módulo convierte una solicitud eth_signTypedData_v4 en una explicación: quién puede gastar qué, hasta cuándo y qué parece sospechoso. Reconoce ERC-2612 y los dos mensajes de Permit2 más habituales y, con cualquier otra cosa, lo dice claramente.

what-am-i-signing.mjs (viem 2.57.7)
// Explains an eth_signTypedData_v4 request before anyone signs it: domain, digest, and for the
// common approval formats, who may spend what until when.
import { getTypesForEIP712Domain, hashDomain, hashStruct, hashTypedData, maxUint160, maxUint256, maxUint48 } from "viem";

const PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3"; // Uniswap Permit2, same address on every chain it is deployed to
const amount = (v, max) => (BigInt(v) === max ? "UNLIMITED" : BigInt(v).toString());
const when = (t, now) => {
  const s = BigInt(t);
  if (s === maxUint256 || s === maxUint48) return "never";
  const seconds = Number(s - BigInt(now));
  const span = seconds < 2 * 86400 ? `${Math.round(seconds / 60)} minutes` : `${Math.round(seconds / 86400)} days`;
  return `${new Date(Number(s) * 1000).toISOString().slice(0, 16)}Z, in ${span}`;
};

export function describe(request, { connectedChainId, now = Math.floor(Date.now() / 1000), trustedSpenders = [] }) {
  const { domain, primaryType, message } = request;
  const types = Object.fromEntries(Object.entries(request.types).filter(([name]) => name !== "EIP712Domain"));
  const lines = [
    `domain       ${domain.name ?? "-"} v${domain.version ?? "-"}, chain ${domain.chainId}, contract ${domain.verifyingContract}`,
    `type         ${primaryType}`,
    // The two hashes a hardware wallet shows when it cannot decode the message, and the signed digest.
    `domain hash  ${hashDomain({ domain, types: { EIP712Domain: getTypesForEIP712Domain({ domain }) } })}`,
    `message hash ${hashStruct({ data: message, primaryType, types })}`,
    `digest       ${hashTypedData({ domain, types, primaryType, message })}`,
  ];
  const flags = [];
  if (domain.chainId !== undefined && Number(domain.chainId) !== connectedChainId) {
    flags.push(`domain is for chain ${domain.chainId}, wallet is on ${connectedChainId}`);
  }
  const spenderCheck = (spender) => {
    if (!trustedSpenders.some((s) => s.toLowerCase() === spender.toLowerCase())) flags.push(`spender ${spender} is not one you expect`);
  };

  if (primaryType === "Permit" && "spender" in message && "value" in message) {
    // ERC-2612: verifyingContract is the token itself.
    lines.push(`action       token ${domain.verifyingContract} lets ${message.spender} spend ${amount(message.value, maxUint256)}, signature valid until ${when(message.deadline, now)}`);
    if (BigInt(message.value) === maxUint256) flags.push("unlimited allowance");
    spenderCheck(message.spender);
  } else if (primaryType === "PermitSingle" || primaryType === "PermitTransferFrom") {
    if (domain.name !== "Permit2" || domain.verifyingContract.toLowerCase() !== PERMIT2) {
      flags.push(`claims to be a Permit2 message but the domain is ${domain.name} at ${domain.verifyingContract}`);
    }
    if (primaryType === "PermitSingle") {
      const d = message.details;
      lines.push(`action       ${message.spender} may pull ${amount(d.amount, maxUint160)} of ${d.token} until ${when(d.expiration, now)}; signature valid until ${when(message.sigDeadline, now)}`);
      if (BigInt(d.amount) === maxUint160) flags.push("unlimited allowance");
      if (BigInt(d.expiration) === maxUint48) flags.push("allowance never expires");
    } else {
      lines.push(`action       ${message.spender} may transfer ${amount(message.permitted.amount, maxUint256)} of ${message.permitted.token} once; valid until ${when(message.deadline, now)}`);
    }
    spenderCheck(message.spender);
  } else {
    lines.push("action       not a known approval format: read every field before signing");
  }
  return [...lines, ...flags.map((f) => `RISK         ${f}`)].join("\n");
}

Tres solicitudes que podría enviar un sitio, construidas con una clave generada para esta ejecución: un permit de USDC ilimitado a tres años para una dirección que el usuario nunca ha visto, un límite de gasto de Permit2 ilimitado y sin caducidad para la misma dirección, y el límite de gasto acotado de Permit2 que de verdad necesita una interfaz de intercambio (swap). Después se firma la primera y se prueba la firma con dominios alterados:

run.mjs
// Lab driver: three signature requests a site might send, explained; then sign one and check domain separation.
import { hashDomain, maxUint160, maxUint256, maxUint48, recoverTypedDataAddress } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { describe } from "./what-am-i-signing.mjs";

const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const ROUTER = "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af"; // Uniswap Universal Router 2.0 on Ethereum
const UNKNOWN = "0x5e1f0000000000000000000000000000000c0ffe"; // an address the user has never dealt with
const now = Math.floor(Date.now() / 1000);
const owner = privateKeyToAccount(generatePrivateKey()); // fresh key for this run

// The token's EIP-712 domain, checked against the value read from the USDC contract on chain.
const usdcDomain = { name: "USD Coin", version: "2", chainId: 1, verifyingContract: USDC };
console.log(`USDC domain separator matches the contract's DOMAIN_SEPARATOR(): ${
  hashDomain({ domain: usdcDomain, types: { EIP712Domain: [
    { name: "name", type: "string" }, { name: "version", type: "string" },
    { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" },
  ] } }) === "0x06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335"
}\n`);

const requests = {
  "1. ERC-2612 permit": {
    domain: usdcDomain,
    types: { Permit: [
      { name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "value", type: "uint256" },
      { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" },
    ] },
    primaryType: "Permit",
    message: { owner: owner.address, spender: UNKNOWN, value: maxUint256, nonce: 0n, deadline: BigInt(now + 3 * 365 * 86400) },
  },
  "2. Permit2 allowance": {
    domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" },
    types: {
      PermitSingle: [{ name: "details", type: "PermitDetails" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }],
      PermitDetails: [{ name: "token", type: "address" }, { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" }, { name: "nonce", type: "uint48" }],
    },
    primaryType: "PermitSingle",
    message: { details: { token: USDC, amount: maxUint160, expiration: maxUint48, nonce: 0n }, spender: UNKNOWN, sigDeadline: BigInt(now + 1800) },
  },
  "3. Permit2 swap, as a router sends it": {
    domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" },
    types: {
      PermitSingle: [{ name: "details", type: "PermitDetails" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }],
      PermitDetails: [{ name: "token", type: "address" }, { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" }, { name: "nonce", type: "uint48" }],
    },
    primaryType: "PermitSingle",
    message: { details: { token: USDC, amount: 250_000_000n, expiration: BigInt(now + 30 * 86400), nonce: 3n }, spender: ROUTER, sigDeadline: BigInt(now + 1800) },
  },
};
for (const [label, request] of Object.entries(requests)) {
  console.log(`${label}\n${describe(request, { connectedChainId: 1, now, trustedSpenders: [ROUTER] })}\n`);
}

// Sign request 1, then see what the same signature proves under a different domain.
const permit = requests["1. ERC-2612 permit"];
const signature = await owner.signTypedData(permit);
console.log(`signature ${signature.slice(0, 20)}... (${(signature.length - 2) / 2} bytes)`);
for (const [label, domain] of [
  ["as signed", permit.domain],
  ["same message, chainId 8453", { ...permit.domain, chainId: 8453 }],
  ["same message, version \"1\"", { ...permit.domain, version: "1" }],
]) {
  const signer = await recoverTypedDataAddress({ ...permit, domain, signature });
  console.log(`${label.padEnd(30)} recovers ${signer === owner.address ? "the owner" : `another address, ${signer.slice(0, 10)}...`}`);
}
Salida (node:24.22.0-bookworm-slim, 20 de octubre de 2026)
USDC domain separator matches the contract's DOMAIN_SEPARATOR(): true

1. ERC-2612 permit
domain       USD Coin v2, chain 1, contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
type         Permit
domain hash  0x06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335
message hash 0xd31ad88aaf46e1d347583c08985fca18fdbd119103263d1d015ca3d30a3518f7
digest       0xbd5b314b039b713da90e540e7f341e381b00ca3b13a268e83697717d653f881e
action       token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 lets 0x5e1f0000000000000000000000000000000c0ffe spend UNLIMITED, signature valid until 2029-10-19T13:01Z, in 1095 days
RISK         unlimited allowance
RISK         spender 0x5e1f0000000000000000000000000000000c0ffe is not one you expect

2. Permit2 allowance
domain       Permit2 v-, chain 1, contract 0x000000000022D473030F116dDEE9F6B43aC78BA3
type         PermitSingle
domain hash  0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28
message hash 0xb8b765bce2e69b8175af5e2dac9689673ce2f98eefb3be07ff08f80471ed8efe
digest       0x8fcfc0fb826d723eacfdbf5d39a4ee0a407bd9b93533c22bae7263eccccab894
action       0x5e1f0000000000000000000000000000000c0ffe may pull UNLIMITED of 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 until never; signature valid until 2026-10-20T13:31Z, in 30 minutes
RISK         unlimited allowance
RISK         allowance never expires
RISK         spender 0x5e1f0000000000000000000000000000000c0ffe is not one you expect

3. Permit2 swap, as a router sends it
domain       Permit2 v-, chain 1, contract 0x000000000022D473030F116dDEE9F6B43aC78BA3
type         PermitSingle
domain hash  0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28
message hash 0x4c4dfafb80b5180321026fdb0db11175968834f982b49f812a02f5e780d17036
digest       0x7399baf230812abc18116a5a75c85e2d1fbf97f6e50e6b053e46f185f897f79f
action       0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af may pull 250000000 of 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 until 2026-11-19T13:01Z, in 30 days; signature valid until 2026-10-20T13:31Z, in 30 minutes

signature 0x163c834f3cae48501f... (65 bytes)
as signed                      recovers the owner
same message, chainId 8453     recovers another address, 0xB19b7dD9...
same message, version "1"      recovers another address, 0xe46542B2...

Cómo leer el resultado:

  • El hash del dominio de USDC es igual al valor leído del contrato de USDC, y el hash del dominio de Permit2 es igual al DOMAIN_SEPARATOR de Permit2 en la red principal. Una solicitud cuyo hash del dominio difiere del que tiene el contrato es para otro contrato u otra cadena, diga lo que diga su name.
  • Las solicitudes 1 y 2 entregan todos los USDC que el propietario llegue a tener a una dirección desconocida: una durante tres años, la otra para siempre. El plazo de la firma de la solicitud 2 es de 30 minutos, lo que parece seguro y no lo es: el plazo limita cuándo se puede enviar la firma, mientras que el límite de gasto que crea no tiene fin.
  • La solicitud 3 muestra cómo es un intercambio legítimo: un router conocido, 250 USDC (USDC tiene 6 decimales, así que 250000000 son 250 unidades), 30 días.
  • La firma está ligada a su dominio. Con otro ID de cadena u otra versión recupera una dirección distinta, así que allí no vale nada.

Firma a ciegas en billeteras de hardware

Cuando una billetera de hardware no puede decodificar un mensaje para mostrarlo en la pantalla del dispositivo, firma a ciegas. La aplicación de Ethereum de Ledger tiene desde hace tiempo un modo que muestra solo el hash del dominio y el hash del mensaje (app-ethereum PR #327); la firma en claro completa (clear signing) necesita un descriptor ERC-7730 para el dominio, y sin él el dispositivo vuelve a la firma a ciegas (documentación de Ledger). Las líneas domain hash y message hash de la salida anterior son exactamente los valores que hay que comparar: calcúlalos en un segundo equipo a partir del JSON de la solicitud y niégate a firmar si cualquiera de ellos difiere del que muestra el dispositivo.

Transacciones multifirma: el caso de Safe

Los propietarios de un Safe (antes Gnosis Safe) también aprueban transacciones firmando datos EIP-712: una estructura SafeTx con el destino to, value, data, un campo operation (0 para una llamada, 1 para un delegatecall), parámetros de gas y un nonce, bajo un dominio formado por chainId y la dirección del propio Safe (EIP712Domain(uint256 chainId,address verifyingContract) desde Safe 1.3.0, según Safe.sol). Un delegatecall ejecuta el código de otro contrato con el almacenamiento y el saldo del propio Safe, así que un SafeTx con operation: 1 puede sustituir por completo la lógica de la billetera.

Es lo que le ocurrió a la plataforma de intercambio Bybit el 21 de febrero de 2025. Según la investigación de Sygnia, el JavaScript que servía la propia infraestructura web de Safe{Wallet} se había modificado para que se activara solo con la billetera fría de Bybit. Los firmantes creían estar aprobando una transferencia rutinaria a una billetera templada (warm wallet); la transacción que firmaron usaba operation = 1 para delegar la ejecución en un contrato desplegado de antemano, que sustituyó la implementación de la billetera y permitió a los atacantes vaciarla de más de 400.000 ETH sin más firmas (cronología de Bybit). La interfaz mintió; los hashes no lo habrían hecho.

safe-tx.mjs
// What a Safe owner signs: SafeTx typed data. One changed field changes the message hash and the
// final hash, while the domain hash stays the same. Addresses are random, generated for the run.
import { hashDomain, hashStruct, hashTypedData, keccak256, parseEther, toHex, zeroAddress } from "viem";
import { generatePrivateKey, privateKeyToAddress } from "viem/accounts";

const SAFE_TX =
  "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
console.log(`SafeTx type hash equals SAFE_TX_TYPEHASH in Safe.sol: ${keccak256(toHex(SAFE_TX)) === "0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8"}`);

const types = {
  SafeTx: [
    { name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "data", type: "bytes" },
    { name: "operation", type: "uint8" }, { name: "safeTxGas", type: "uint256" }, { name: "baseGas", type: "uint256" },
    { name: "gasPrice", type: "uint256" }, { name: "gasToken", type: "address" }, { name: "refundReceiver", type: "address" },
    { name: "nonce", type: "uint256" },
  ],
};
const random = () => privateKeyToAddress(generatePrivateKey());
const safe = random();
const warmWallet = random();
const otherContract = random();
const domain = { chainId: 1, verifyingContract: safe }; // Safe 1.3.0+: EIP712Domain(uint256 chainId,address verifyingContract)

const transfer = {
  to: warmWallet, value: parseEther("30000"), data: "0x", operation: 0,
  safeTxGas: 0n, baseGas: 0n, gasPrice: 0n, gasToken: zeroAddress, refundReceiver: zeroAddress, nonce: 42n,
};
for (const [label, message] of [
  ["transfer 30000 ETH (operation 0, call)", transfer],
  ["same fields, operation 1 (delegatecall)", { ...transfer, operation: 1 }],
  ["delegatecall to another contract, value 0", { ...transfer, to: otherContract, value: 0n, operation: 1 }],
]) {
  console.log(`\n${label}`);
  console.log(`  domain hash  ${hashDomain({ domain, types: { EIP712Domain: [{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }] } })}`);
  console.log(`  message hash ${hashStruct({ data: message, primaryType: "SafeTx", types })}`);
  console.log(`  safeTxHash   ${hashTypedData({ domain, types, primaryType: "SafeTx", message })}`);
}
Salida (node:24.22.0-bookworm-slim)
SafeTx type hash equals SAFE_TX_TYPEHASH in Safe.sol: true

transfer 30000 ETH (operation 0, call)
  domain hash  0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
  message hash 0x2b9d9e60cc9312044229f6a8483dc86eaf49b8cc03ac37af3a44b1e573d28316
  safeTxHash   0x46beeb0e67f4c0c3e3ec76e6ee8feabaad2f5df52ec7afe4bfc192d7592dd99e

same fields, operation 1 (delegatecall)
  domain hash  0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
  message hash 0xc9e7bcc6b496ee176fc021a8d440e3660fbea74ec2da65038e519b4220d5c8af
  safeTxHash   0xaf671685ec110193631edb37aa05c813e9cd9e1a9fad061872d44c10e064d00e

delegatecall to another contract, value 0
  domain hash  0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
  message hash 0xcd1a4444a84bf0867dfe41eec2b961b83e187c8fc8262efa2c92e2f4343c0ba3
  safeTxHash   0x7c298ccf189f829006f8503d0be25293e358a45884e08ce576f94d5eae0d0148

El hash del dominio es idéntico en los tres casos: solo dice «este Safe en esta cadena». Basta con cambiar el campo operation para que cambie el hash del mensaje y, por tanto, el hash final. Un firmante que recalcula safeTxHash a partir de la transacción que pretende aprobar, en un equipo que no ejecuta la interfaz web de la billetera, y lo compara con lo que muestra el dispositivo de hardware detecta exactamente este tipo de sustitución. La herramienta de hash de transacciones de Safe hace ese cálculo en local.

No toda firma peligrosa es EIP-712

Otros dos formatos de firma merecen el mismo escrutinio, porque las billeteras los muestran de otra manera:

  • personal_sign (EIP-191, versión 0x45) antepone al mensaje "\x19Ethereum Signed Message:\n" y su longitud. Está pensado para texto legible por personas, como un mensaje de Sign-In with Ethereum. Una solicitud de personal_sign sobre una cadena hexadecimal de 32 bytes te pide firmar algo que no puedes leer. Algunas herramientas legítimas lo hacen, sobre todo las billeteras multifirma que, en dispositivos sin compatibilidad con datos tipados, recurren a firmar un hash de transacción, y justo en ese caso debes recalcular el hash tú mismo.
  • Las autorizaciones EIP-7702 (EIP-7702, estado Final) se firman sobre keccak(0x05 ‖ rlp([chain_id, address, nonce])), no con EIP-712. Una autorización establece como código de tu cuenta un puntero a address, de modo que, a partir de entonces, el código de ese contrato se ejecuta como tu cuenta: es una delegación de la cuenta entera, no la aprobación de un token. Un chain_id de 0 hace que la autorización sea válida en todas las cadenas. Se espera que las billeteras las muestren de forma especial; trata como hostil cualquier solicitud de firmar una fuera del propio flujo de actualización de tu billetera. El decodificador de EIP-7702 muestra a qué delega una autorización o una transacción de tipo 4.

Cómo revocar lo firmado

  • Un permit ERC-2612 ya usado es un límite de gasto corriente: revócalo con approve(spender, 0) en el token.
  • Un permit ERC-2612 sin usar sigue siendo válido hasta su plazo o hasta que cambie el nonce del propietario. Usar cualquier permit del mismo token incrementa el nonce e invalida los anteriores que no se hayan usado, pero no hay una «cancelación» directa.
  • Los límites de gasto de Permit2 se pueden revocar con approve(token, spender, 0, 0) o en bloque con lockdown; las firmas PermitSingle sin usar se invalidan con invalidateNonces(token, spender, newNonce), y las firmas SignatureTransfer sin usar, con invalidateUnorderedNonces.
  • La aprobación subyacente concedida al propio Permit2 se mantiene hasta que se revoca en el token; esa aprobación es lo que da valor a cada firma de Permit2.

Para desarrolladores que generan datos tipados

  1. Usa eth_signTypedData_v4, la versión que MetaMask recomienda para datos tipados, y una biblioteca para construir la solicitud: el JSON montado a mano es el origen de errores como ambiguous primary types or unused types en ethers e Invalid primary type en viem.
  2. Incluye chainId y verifyingContract en tu dominio. Sin ellos, una firma para tu aplicación en una cadena es válida en cualquier otra cadena donde la misma lógica de contrato la acepte.
  3. Protege cada acción firmada contra la repetición: un nonce o un conjunto de firmas ya usadas, y un plazo. La sección de seguridad de EIP-712 deja claro que la protección contra repetición es responsabilidad de la aplicación.
  4. Pide exactamente lo que la acción necesita: el importe de esta operación, una caducidad de minutos o días. Las interfaces que piden aprobaciones ilimitadas y sin caducidad acostumbran a los usuarios a firmarlas.
  5. Verifica mediante recuperación y después compara las direcciones, y en las billeteras de contrato inteligente usa en su lugar isValidSignature de ERC-1271; consulta Sign-In with Ethereum para la parte del servidor.

Fuentes