Pular para o conteúdo
ecdsa.com
← Todos os artigos

EIP-712: o que você realmente assina e como interpretá-lo

Uma assinatura de dados tipados não custa gás e não aparece em nenhum explorador de blocos até que alguém a use, mas pode entregar todos os tokens de um tipo por anos. Veja o que são os bytes, o que os formatos de aprovação mais comuns concedem e um programa curto que traduz uma solicitação de assinatura em uma frase simples antes que alguém assine.

Resposta curta. A EIP-712 assina um hash de dados estruturados: keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). O domínio (nome, versão, ID da cadeia, contrato verificador) diz a qual contrato, em qual cadeia, a assinatura se destina; a mensagem é uma struct tipada. Antes de assinar, leia quatro coisas: o verifyingContract e o chainId (este é o contrato que você imagina, na cadeia em que você está?), o primaryType (uma aprovação, uma ordem, um login?), o spender ou o destinatário, e os valores e prazos. Uma mensagem Permit da ERC-2612 ou Permit2 da Uniswap com o valor máximo e um prazo distante é uma aprovação ilimitada que qualquer pessoa que tenha a assinatura pode ativar depois. Se sua carteira de hardware mostrar só um hash do domínio e um hash da mensagem, recalcule os dois em outro dispositivo e compare.

O que são os bytes

A EIP-712 (status Final) define três peças:

  • encodeType: um tipo de struct como string, Mail(Person from,Person to,string contents), seguido de todos os tipos de struct que ele referencia, ordenados por nome: …Person(string name,address wallet). O keccak256 dessa string é o hash do tipo.
  • hashStruct(s) = keccak256(typeHash ‖ encodeData(s)), em que cada membro é codificado em 32 bytes: inteiros e endereços como palavras ABI, string e bytes como o keccak256 do seu conteúdo, structs aninhadas como o próprio hashStruct delas e arrays como o keccak256 da concatenação de suas codificações.
  • domainSeparator = hashStruct(EIP712Domain), calculado sobre os campos que a aplicação usa dentre name, version, chainId, verifyingContract e salt, nessa ordem.

O digest assinado é keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). O prefixo 0x19 garante que ele nunca seja uma transação válida (RLP nunca começa com 0x19), e o byte de versão 0x01 o separa do formato 0x19 "Ethereum Signed Message" do personal_sign, definido na EIP-191. A assinatura é uma assinatura ECDSA secp256k1 comum, de 65 bytes com o byte de recuperação, e o endereço do signatário é recuperado a partir dela.

O próprio exemplo da especificação, recalculado com o viem só a partir de dados públicos (os dados tipados e os valores 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 })}`);
Saída (node:24.22.0-bookworm-slim)
typeHash(Mail)     0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail)   0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator    0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest             0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer             0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826

Todos os valores conferem com os esperados no Example.js da EIP, e a assinatura recupera o signatário do exemplo.

O que o domínio protege e o que não protege

O domínio torna uma assinatura válida para um único contrato em uma única cadeia. A mesma mensagem assinada para a cadeia 1 não passa na verificação para a cadeia 8453, e uma mudança de version em um token invalida todas as assinaturas pendentes. A especificação diz que uma carteira “deveria se recusar a assinar” quando o chainId não corresponde à cadeia ativa; o MetaMask faz isso, com Provided chainId "1" must match the active chainId "137".

O que o domínio não consegue fazer é dizer se o contrato é o verdadeiro. Um site de phishing pode pedir uma assinatura com name: "USD Coin" e o endereço de um contrato próprio; só o verifyingContract, comparado com um endereço em que você confia, resolve essa questão. Em tokens com ERC-2612, o verifyingContract é o próprio contrato do token; no Permit2, é o contrato do Permit2; em ordens de marketplace, é o contrato da exchange.

Os formatos que movimentam tokens

O permit da ERC-2612 (ERC-2612, status Final) é um approve por assinatura: Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). Qualquer pessoa que tenha a assinatura pode enviá-la antes do deadline; o token então define allowance[owner][spender] = value e incrementa o nonce do proprietário. O próprio padrão observa que o deadline “pode ser definido como uint(-1) para criar permits que, na prática, nunca expiram”, e um value de 2^256 − 1 é o “ilimitado” convencional. Nada aparece na blockchain até a assinatura ser usada, então a lista de aprovações de uma carteira não a mostra.

O Permit2 da Uniswap é um único contrato, no endereço 0x000000000022D473030F116dDEE9F6B43aC78BA3 em todas as cadeias em que foi implantado, que guarda as aprovações de tokens de muitas aplicações. O usuário aprova o Permit2 uma vez, na blockchain; depois disso, cada aplicação obtém acesso por assinatura:

  • PermitSingle / PermitBatch (AllowanceTransfer) concedem uma allowance: PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce), um spender e um sigDeadline para a própria assinatura. Um amount de 2^160 − 1 é ilimitado e não diminui com as transferências; um expiration de 2^48 − 1 nunca expira, e 0 significa “apenas neste bloco”.
  • PermitTransferFrom / PermitBatchTransferFrom (SignatureTransfer) autorizam uma única transferência de até amount antes do deadline, com um nonce de uso único. São mais seguros: nada fica pendente.
  • As variantes witness acrescentam à mesma assinatura uma struct da aplicação, como uma ordem da UniswapX.

O domínio do Permit2 tem name: "Permit2", chainId e verifyingContract, e nenhum version. As strings de tipo acima foram tiradas do PermitHash.sol do Permit2.

Como ler uma solicitação antes de assiná-la

Este módulo transforma uma solicitação eth_signTypedData_v4 em uma descrição clara: quem pode gastar o quê, até quando e o que parece errado. Ele reconhece a ERC-2612 e as duas mensagens mais comuns do Permit2 e, para qualquer outra coisa, diz claramente que não a reconhece.

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");
}

Três solicitações que um site poderia enviar, montadas com uma chave gerada para esta execução: um permit de USDC ilimitado, válido por três anos, para um endereço que o usuário nunca viu; uma allowance do Permit2 ilimitada e sem expiração para o mesmo endereço; e a allowance limitada do Permit2 de que uma interface de swap realmente precisa. Em seguida, a primeira é assinada, e a assinatura é testada com domínios 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)}...`}`);
}
Saída (node:24.22.0-bookworm-slim, 20 de outubro 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...

Como ler a saída:

  • O hash do domínio do USDC é igual ao valor lido do contrato do USDC, e o hash do domínio do Permit2 é igual ao DOMAIN_SEPARATOR do Permit2 na mainnet. Uma solicitação cujo hash do domínio difere do hash do contrato se destina a outro contrato ou a outra cadeia, diga o name o que disser.
  • As solicitações 1 e 2 entregam todo o USDC que o proprietário vier a ter a um endereço desconhecido: uma por três anos, a outra para sempre. O prazo da assinatura da solicitação 2 é de 30 minutos, o que parece seguro e não é: o prazo limita quando a assinatura pode ser enviada, enquanto a allowance que ela cria não tem fim.
  • A solicitação 3 mostra como é um swap legítimo: um roteador conhecido, 250 USDC (o USDC tem 6 casas decimais, então 250000000 são 250 unidades), 30 dias.
  • A assinatura está vinculada ao seu domínio. Com outro ID da cadeia ou outra versão, ela recupera um endereço diferente e, portanto, não vale nada ali.

Assinatura às cegas em carteiras de hardware

Quando uma carteira de hardware não consegue decodificar uma mensagem para a tela do dispositivo, ela assina às cegas. O app Ethereum da Ledger tem há muito tempo um modo que mostra apenas o hash do domínio e o hash da mensagem (PR #327 do app-ethereum); a assinatura legível completa (clear signing) exige um descritor ERC-7730 para o domínio e, sem ele, o dispositivo volta à assinatura às cegas (documentação da Ledger). As linhas domain hash e message hash da saída acima são exatamente os valores a comparar: calcule-os em uma segunda máquina a partir do JSON da solicitação e recuse se algum deles for diferente do que o dispositivo mostra.

Transações multisig: o caso do Safe

Os proprietários de um Safe (antigo Gnosis Safe) também aprovam transações assinando dados EIP-712: uma struct SafeTx com o destino to, value, data, um campo operation (0 para uma chamada, 1 para um delegatecall), parâmetros de gás e um nonce, sob um domínio formado pelo chainId e pelo endereço do próprio Safe (EIP712Domain(uint256 chainId,address verifyingContract) desde o Safe 1.3.0, segundo o Safe.sol). Um delegatecall executa o código de outro contrato com o armazenamento e o saldo do próprio Safe, então uma SafeTx com operation: 1 pode substituir por completo a lógica da carteira.

Foi o que aconteceu com a exchange Bybit em 21 de fevereiro de 2025. Segundo a investigação da Sygnia, o JavaScript servido pela própria infraestrutura web do Safe{Wallet} tinha sido modificado para ser ativado apenas para a carteira fria da Bybit. Os signatários acreditavam estar aprovando uma transferência de rotina para uma carteira quente; a transação que assinaram usava operation = 1 para delegar a execução a um contrato implantado previamente, que substituiu a implementação da carteira e permitiu que os atacantes drenassem mais de 400.000 ETH sem novas assinaturas (linha do tempo da Bybit). A interface mentiu; os hashes não teriam mentido.

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 })}`);
}
Saída (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

O hash do domínio é idêntico nos três casos: ele só diz “este Safe nesta cadeia”. Mudar apenas o campo operation altera o hash da mensagem e, portanto, o hash final. Se o signatário recalcular o safeTxHash a partir da transação que pretende fazer, em uma máquina que não executa a interface web da carteira, e compará-lo com o que o dispositivo de hardware mostra, detectará exatamente esse tipo de substituição. A ferramenta de hash de transação do Safe faz esse cálculo localmente.

Nem toda assinatura perigosa é EIP-712

Dois outros formatos de assinatura merecem o mesmo escrutínio, porque as carteiras os exibem de outra maneira:

  • personal_sign (EIP-191, versão 0x45) prefixa a mensagem com "\x19Ethereum Signed Message:\n" e o comprimento dela. Ele se destina a texto legível por humanos, como uma mensagem do Sign-In with Ethereum. Uma solicitação para assinar com personal_sign uma string hexadecimal de 32 bytes pede que você assine algo que não consegue ler. Algumas ferramentas legítimas fazem isso, em especial carteiras multisig que recorrem à assinatura do hash de uma transação em dispositivos sem suporte a dados tipados — e é exatamente nesse caso que você precisa recalcular o hash por conta própria.
  • As autorizações EIP-7702 (EIP-7702, status Final) são assinadas sobre keccak(0x05 ‖ rlp([chain_id, address, nonce])), e não com EIP-712. Uma autorização define o código da sua conta como um ponteiro para address, de modo que, a partir daí, o código desse contrato é executado como se fosse a sua conta: é uma delegação da conta inteira, e não a aprovação de um token. Um chain_id igual a 0 torna a autorização válida em todas as cadeias. A expectativa é que as carteiras exibam essas autorizações de forma especial; trate como hostil qualquer solicitação para assinar uma delas fora do fluxo de atualização da própria carteira. O decodificador de EIP-7702 mostra para onde uma autorização ou uma transação do tipo 4 delega.

Como revogar o que você assinou

  • Um permit da ERC-2612 já usado é uma allowance comum: revogue com approve(spender, 0) no token.
  • Um permit da ERC-2612 não usado continua válido até o prazo ou até o nonce do proprietário mudar. Usar qualquer permit do mesmo token incrementa o nonce e invalida os permits mais antigos não usados, mas não existe um “cancelamento” direto.
  • As allowances do Permit2 podem ser revogadas com approve(token, spender, 0, 0) ou em massa com lockdown; assinaturas PermitSingle não usadas são invalidadas por invalidateNonces(token, spender, newNonce), e assinaturas SignatureTransfer não usadas, por invalidateUnorderedNonces.
  • A aprovação subjacente ao próprio Permit2 permanece até ser revogada no token; é essa aprovação que dá valor a toda assinatura do Permit2.

Para desenvolvedores que geram dados tipados

  1. Use eth_signTypedData_v4, a versão que o MetaMask recomenda para dados tipados, e uma biblioteca para montar a solicitação: JSON montado à mão é a origem de erros como ambiguous primary types or unused types no ethers e Invalid primary type no viem.
  2. Inclua chainId e verifyingContract no seu domínio. Sem eles, uma assinatura feita para seu app em uma cadeia é válida em todas as outras cadeias em que a mesma lógica de contrato a aceitar.
  3. Proteja contra repetição toda ação assinada: um nonce ou um conjunto de assinaturas já usadas, e um prazo. A seção de segurança da EIP-712 deixa explícito que a proteção contra repetição é responsabilidade da aplicação.
  4. Peça exatamente o que a ação exige: o valor desta negociação, uma expiração em minutos ou dias. Interfaces que pedem aprovações ilimitadas e sem expiração treinam os usuários a assiná-las.
  5. Verifique por recuperação e depois compare os endereços; para carteiras de contrato inteligente, use em vez disso o isValidSignature da ERC-1271. Veja Sign-In with Ethereum para o lado do servidor.

Fontes