Aller au contenu
ecdsa.com
← Tous les articles

EIP-712 : ce que vous signez vraiment, et comment le lire

Une signature de données typées ne coûte pas de gaz et n’apparaît dans aucun explorateur de blocs tant que personne ne l’utilise, et pourtant elle peut céder pendant des années tous les jetons d’un type donné. Voici ce que sont ces octets, ce qu’accordent les formats d’approbation courants, et un court programme qui traduit une demande de signature en énoncé clair avant que quiconque ne signe.

En bref. EIP-712 signe un condensat de données structurées : keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). Le domaine (nom, version, ID de chaîne, contrat vérificateur) indique pour quel contrat et sur quelle chaîne la signature vaut ; le message est une structure typée. Avant de signer, lisez quatre choses : le verifyingContract et le chainId (est-ce bien le contrat que vous croyez, sur la chaîne où vous êtes ?), le primaryType (une approbation, un ordre, une connexion ?), le spender (l’adresse autorisée à dépenser) ou le destinataire, et les montants et les échéances. Un message ERC-2612 Permit ou Uniswap Permit2 avec le montant maximal et une échéance lointaine est une approbation illimitée que toute personne détenant la signature peut activer plus tard. Si votre portefeuille matériel n’affiche qu’un condensat du domaine et un condensat du message, recalculez-les tous les deux sur un autre appareil et comparez.

Ce que sont ces octets

EIP-712 (statut Final) définit trois éléments :

  • encodeType : un type de structure sous forme de chaîne, Mail(Person from,Person to,string contents), suivi de tous les types de structure qu’il référence, triés par nom : …Person(string name,address wallet). Son keccak256 est le condensat de type (type hash).
  • hashStruct(s) = keccak256(typeHash ‖ encodeData(s)), où chaque membre est encodé sur 32 octets : les entiers et les adresses comme des mots ABI, string et bytes comme le keccak256 de leur contenu, les structures imbriquées comme leur propre hashStruct, les tableaux comme le keccak256 de la concaténation de leurs encodages.
  • domainSeparator = hashStruct(EIP712Domain), calculé sur ceux des champs name, version, chainId, verifyingContract et salt qu’utilise l’application, dans cet ordre.

Le condensat signé est keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). Le préfixe 0x19 garantit qu’il ne peut jamais être une transaction valide (RLP ne commence jamais par 0x19), et l’octet de version 0x01 le distingue du format 0x19 "Ethereum Signed Message" de personal_sign, défini par EIP-191. La signature est une signature ECDSA secp256k1 ordinaire, de 65 octets avec l’octet de récupération, et c’est d’elle qu’on récupère l’adresse du signataire.

L’exemple de la spécification elle-même, recalculé avec viem à partir de données publiques uniquement (les données typées et les valeurs v, r, s publiées) :

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 })}`);
Sortie (node:24.22.0-bookworm-slim)
typeHash(Mail)     0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail)   0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator    0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest             0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer             0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826

Chaque valeur correspond aux attentes du fichier Example.js de l’EIP, et la signature permet de récupérer le signataire de l’exemple.

Ce que le domaine protège, et ce qu’il ne protège pas

Le domaine rend une signature valide pour un seul contrat sur une seule chaîne. Le même message signé pour la chaîne 1 ne passe pas la vérification sur la chaîne 8453, et un changement de version d’un jeton invalide toutes les signatures pas encore utilisées. La spécification indique qu’un portefeuille « devrait refuser de signer » quand chainId ne correspond pas à la chaîne active ; MetaMask le fait, avec l’erreur Provided chainId "1" must match the active chainId "137".

Ce que le domaine ne peut pas faire, c’est vous dire si le contrat est le vrai. Un site d’hameçonnage peut demander une signature avec name: "USD Coin" et sa propre adresse de contrat ; seul le verifyingContract, comparé à une adresse en laquelle vous avez confiance, tranche la question. Pour les jetons ERC-2612, verifyingContract est le contrat du jeton lui-même ; pour Permit2, c’est le contrat Permit2 ; pour les ordres d’une place de marché, le contrat de la plateforme d’échange.

Les formats qui déplacent des jetons

ERC-2612 permit (ERC-2612, statut Final) est un approve par signature : Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). Quiconque détient la signature peut la soumettre avant deadline ; le jeton fixe alors allowance[owner][spender] = value et incrémente le nonce du propriétaire. La norme elle-même précise que deadline « peut être fixé à uint(-1) pour créer des Permits qui n’expirent pratiquement jamais », et une value de 2^256 − 1 est la valeur « illimitée » conventionnelle. Rien n’apparaît sur la chaîne tant que la signature n’est pas utilisée, si bien que la liste des approbations d’un portefeuille ne l’affiche pas.

Uniswap Permit2 est un contrat unique, à l’adresse 0x000000000022D473030F116dDEE9F6B43aC78BA3 sur chaque chaîne où il est déployé, qui détient les approbations de jetons de nombreuses applications. L’utilisateur approuve Permit2 une seule fois sur la chaîne ; ensuite, chaque application obtient l’accès par signature :

  • PermitSingle / PermitBatch (AllowanceTransfer) accordent un montant autorisé : PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce), un spender et un sigDeadline pour la signature elle-même. Un amount de 2^160 − 1 est illimité et n’est pas diminué par les transferts ; une expiration de 2^48 − 1 n’expire jamais, et 0 signifie « uniquement dans ce bloc ».
  • PermitTransferFrom / PermitBatchTransferFrom (SignatureTransfer) autorisent un seul transfert d’au plus amount avant deadline, avec un nonce à usage unique. Ils sont plus sûrs : rien ne subsiste.
  • Les variantes witness (avec témoin) ajoutent à la même signature une structure propre à l’application, comme un ordre UniswapX.

Le domaine de Permit2 comporte name: "Permit2", chainId et verifyingContract, sans version. Les chaînes de type ci-dessus proviennent du fichier PermitHash.sol de Permit2.

Lire une demande avant de la signer

Ce module transforme une requête eth_signTypedData_v4 en énoncé : qui peut dépenser quoi, jusqu’à quand, et ce qui semble anormal. Il reconnaît ERC-2612 et les deux messages Permit2 les plus courants, et indique clairement, pour tout autre message, qu’il ne le reconnaît pas.

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

Trois requêtes qu’un site pourrait envoyer, construites avec une clé générée pour l’exécution : un permit USDC illimité de trois ans en faveur d’une adresse que l’utilisateur n’a jamais vue, un montant autorisé Permit2 illimité et sans expiration en faveur de la même adresse, et le montant autorisé Permit2 borné dont une interface de swap a réellement besoin. Ensuite, la première est signée, et la signature est mise à l’épreuve sous des domaines modifiés :

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)}...`}`);
}
Sortie (node:24.22.0-bookworm-slim, 20 octobre 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...

Lecture du résultat :

  • Le condensat du domaine USDC est égal à la valeur lue dans le contrat USDC, et le condensat du domaine Permit2 est égal au DOMAIN_SEPARATOR de Permit2 sur le réseau principal. Une requête dont le condensat du domaine diffère de celui du contrat vise un autre contrat ou une autre chaîne, quoi qu’en dise son name.
  • Les requêtes 1 et 2 cèdent tous les USDC que le propriétaire possédera jamais à une adresse inconnue : l’une pour trois ans, l’autre pour toujours. L’échéance de signature de la requête 2 est de 30 minutes, ce qui semble sûr et ne l’est pas : l’échéance limite le moment où la signature peut être soumise, tandis que le montant autorisé qu’elle crée n’a pas de fin.
  • La requête 3 est ce à quoi ressemble un swap légitime : un routeur connu, 250 USDC (USDC a 6 décimales, donc 250000000 correspond à 250 unités), 30 jours.
  • La signature est liée à son domaine. Avec un autre ID de chaîne ou une autre version, elle permet de récupérer une adresse différente, et ne vaut donc rien dans ce contexte.

La signature à l’aveugle sur les portefeuilles matériels

Quand un portefeuille matériel ne parvient pas à décoder un message pour l’écran de l’appareil, il signe à l’aveugle. L’application Ethereum de Ledger dispose depuis longtemps d’un mode qui n’affiche que le condensat du domaine et le condensat du message (app-ethereum PR #327) ; la signature en clair complète (clear signing) exige un descripteur ERC-7730 pour le domaine, et sans descripteur l’appareil se rabat sur la signature à l’aveugle (documentation de Ledger). Les lignes domain hash et message hash de la sortie ci-dessus sont exactement les valeurs à comparer : calculez-les sur une seconde machine à partir du JSON de la requête, et refusez de signer si l’une d’elles diffère de ce qu’affiche l’appareil.

Transactions multisig : le cas Safe

Les propriétaires d’un Safe (anciennement Gnosis Safe) approuvent eux aussi les transactions en signant des données EIP-712 : une structure SafeTx contenant la cible to, value, data, un champ operation (0 pour un appel simple, 1 pour un delegatecall), des paramètres de gaz et un nonce, sous un domaine formé du chainId et de l’adresse du Safe lui-même (EIP712Domain(uint256 chainId,address verifyingContract) depuis Safe 1.3.0, d’après Safe.sol). Un delegatecall exécute le code d’un autre contrat avec le stockage et le solde du Safe lui-même, si bien qu’une SafeTx avec operation: 1 peut remplacer entièrement la logique du portefeuille.

C’est ce qui est arrivé à la plateforme d’échange Bybit le 21 février 2025. Selon l’enquête de Sygnia, le JavaScript servi par l’infrastructure web de Safe{Wallet} elle-même avait été modifié pour ne s’activer qu’avec le portefeuille froid de Bybit. Les signataires croyaient approuver un transfert de routine vers un warm wallet ; la transaction qu’ils ont signée utilisait operation = 1 pour déléguer l’exécution à un contrat déployé au préalable, qui a remplacé l’implémentation du portefeuille et permis aux attaquants de siphonner plus de 400 000 ETH sans autre signature (chronologie de Bybit). L’interface a menti ; les condensats, eux, n’auraient pas menti.

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 })}`);
}
Sortie (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

Le condensat du domaine est identique dans les trois cas : il dit seulement « ce Safe, sur cette chaîne ». Basculer le seul champ operation change le condensat du message, et donc le condensat final. Un signataire qui recalcule safeTxHash à partir de la transaction qu’il entend passer, sur une machine qui n’exécute pas l’interface web du portefeuille, et le compare avec ce qu’affiche l’appareil matériel, détecte exactement ce genre de substitution. L’outil de hash de transaction Safe effectue ce calcul localement.

Toutes les signatures dangereuses ne relèvent pas d’EIP-712

Deux autres formats de signature méritent la même vigilance, car les portefeuilles les affichent différemment :

  • personal_sign (EIP-191 version 0x45) préfixe le message par "\x19Ethereum Signed Message:\n" et sa longueur. Il est destiné à du texte lisible par un humain, comme un message Sign-In with Ethereum. Une requête personal_sign portant sur une chaîne hexadécimale de 32 octets vous demande de signer quelque chose que vous ne pouvez pas lire. Certains outils légitimes le font, notamment les portefeuilles multisig qui se rabattent sur la signature d’un hash de transaction sur les appareils qui ne prennent pas en charge les données typées, et c’est précisément là que vous devez recalculer ce hash vous-même.
  • Les autorisations EIP-7702 (EIP-7702, statut Final) sont signées sur keccak(0x05 ‖ rlp([chain_id, address, nonce])), et non selon EIP-712. Une seule autorisation remplace le code de votre compte par un pointeur vers address, si bien que le code de ce contrat s’exécute désormais en tant que votre compte : c’est une délégation du compte entier, pas l’approbation d’un seul jeton. Un chain_id égal à 0 rend l’autorisation valide sur toutes les chaînes. Les portefeuilles sont censés les afficher de manière particulière ; considérez comme hostile toute demande d’en signer une en dehors du parcours de mise à niveau propre à votre portefeuille. Le décodeur EIP-7702 montre vers quoi délègue une autorisation ou une transaction de type 4.

Révoquer ce que vous avez signé

  • Un permit ERC-2612 utilisé est un montant autorisé ordinaire : révoquez-le avec approve(spender, 0) sur le jeton.
  • Un permit ERC-2612 non utilisé reste valide jusqu’à son échéance ou jusqu’à ce que le nonce du propriétaire change. Utiliser n’importe quel permit pour le même jeton incrémente le nonce et annule les plus anciens non utilisés, mais il n’existe pas d’« annulation » directe.
  • Les montants autorisés Permit2 peuvent être révoqués avec approve(token, spender, 0, 0) ou en masse avec lockdown ; les signatures PermitSingle non utilisées sont invalidées par invalidateNonces(token, spender, newNonce), et les signatures SignatureTransfer non utilisées par invalidateUnorderedNonces.
  • L’approbation sous-jacente accordée à Permit2 lui-même subsiste jusqu’à sa révocation sur le jeton ; c’est elle qui donne de la valeur à chaque signature Permit2.

Pour les développeurs qui produisent des données typées

  1. Utilisez eth_signTypedData_v4, la version que MetaMask recommande pour les données typées, et une bibliothèque pour la construire : le JSON assemblé à la main est à l’origine d’erreurs comme ambiguous primary types or unused types dans ethers et Invalid primary type dans viem.
  2. Incluez chainId et verifyingContract dans votre domaine. Sans eux, une signature destinée à votre application sur une chaîne est valide sur toutes les autres chaînes où la même logique de contrat l’accepte.
  3. Rendez chaque action signée résistante au rejeu : un nonce ou un ensemble de signatures déjà utilisées, et une échéance. La section sécurité d’EIP-712 dit explicitement que la protection contre le rejeu incombe à l’application.
  4. Demandez exactement ce dont l’action a besoin : le montant de cet échange, une expiration en minutes ou en jours. Les interfaces qui demandent des approbations illimitées et sans expiration habituent les utilisateurs à les signer.
  5. Vérifiez par récupération, puis comparez les adresses, et pour les portefeuilles de contrat intelligent, utilisez plutôt isValidSignature d’ERC-1271 ; consultez Sign-In with Ethereum pour le côté serveur.

Sources