Перейти к содержимому
ecdsa.com
← Все статьи

EIP-712: что именно вы подписываете и как это прочитать

Подпись типизированных данных не расходует газ и не видна ни в одном обозревателе блоков, пока ею кто-нибудь не воспользуется, и всё же она может на годы отдать все токены определённого вида. Здесь разобрано, что это за байты и что разрешают распространённые форматы одобрений, а также приведена короткая программа, которая ещё до подписания превращает запрос подписи в описание простыми словами.

Коротко. В EIP-712 подписывается хеш структурированных данных: keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). Домен (название, версия, chain ID, проверяющий контракт) указывает, для какого контракта в какой сети предназначена подпись; сообщение — типизированная структура. Перед подписанием прочитайте четыре вещи: verifyingContract и chainId (тот ли это контракт, о котором вы думаете, и в той ли он сети, где вы находитесь?), primaryType (одобрение, ордер, вход?), spender или получателя, а также суммы и сроки. Сообщение ERC-2612 Permit или Uniswap Permit2 с максимальной суммой и далёким сроком — это неограниченное одобрение, которое позже может активировать любой, у кого есть подпись. Если аппаратный кошелёк показывает только хеш домена и хеш сообщения, пересчитайте оба на другом устройстве и сравните.

Что это за байты

EIP-712 (статус Final) определяет три составляющие:

  • encodeType: тип структуры в виде строки, Mail(Person from,Person to,string contents), за которым следуют все типы структур, на которые он ссылается, отсортированные по имени: …Person(string name,address wallet). Его keccak256 — это хеш типа.
  • hashStruct(s) = keccak256(typeHash ‖ encodeData(s)), где каждое поле кодируется 32 байтами: целые числа и адреса — как слова ABI, string и bytes — как keccak256 их содержимого, вложенные структуры — как их собственный hashStruct, массивы — как keccak256 конкатенации их кодировок.
  • domainSeparator = hashStruct(EIP712Domain), вычисленный по тем из полей name, version, chainId, verifyingContract и salt, которые использует приложение, именно в этом порядке.

Подписывается дайджест keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). Префикс 0x19 гарантирует, что он никогда не окажется корректной транзакцией (RLP никогда не начинается с 0x19), а байт версии 0x01 отличает его от формата personal_sign из EIP-191 — 0x19 "Ethereum Signed Message". Сама подпись — обычная подпись ECDSA на secp256k1 длиной 65 байт вместе с байтом восстановления, и из неё восстанавливается адрес подписанта.

Пример из самой спецификации, пересчитанный с помощью viem только по публичным данным (типизированные данные и опубликованные v, r, s):

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 })}`);
Результат (node:24.22.0-bookworm-slim)
typeHash(Mail)     0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail)   0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator    0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest             0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer             0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826

Все значения совпадают с ожидаемыми в Example.js из EIP, а из подписи восстанавливается подписант этого примера.

От чего защищает домен, а от чего нет

Домен делает подпись действительной для одного контракта в одной сети. То же сообщение, подписанное для сети 1, не пройдёт проверку для сети 8453, а изменение version токена делает недействительными все ещё не использованные подписи. Спецификация говорит, что кошельку «следует отказаться от подписания», если chainId не совпадает с активной сетью; MetaMask так и поступает и выдаёт ошибку Provided chainId "1" must match the active chainId "137".

А вот определить, настоящий ли это контракт, домен не может. Фишинговый сайт может запросить подпись с name: "USD Coin" и адресом собственного контракта; решает вопрос только verifyingContract, сверенный с адресом, которому вы доверяете. Для токенов с ERC-2612 verifyingContract — это сам контракт токена, для Permit2 — контракт Permit2, для ордеров маркетплейсов — контракт биржи.

Форматы, которые переводят токены

ERC-2612 permit (ERC-2612, Final) — это approve по подписи: Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). Любой, у кого есть подпись, может отправить её до наступления deadline; тогда токен устанавливает allowance[owner][spender] = value и увеличивает nonce владельца. Сам стандарт отмечает, что deadline «можно установить в uint(-1), чтобы создавать permit, срок действия которых фактически никогда не истекает», а value, равное 2^256 − 1, по соглашению означает «без ограничений». Пока подпись не использована, в блокчейне ничего не появляется, поэтому в списке одобрений кошелька её не видно.

Uniswap Permit2 — единый контракт по адресу 0x000000000022D473030F116dDEE9F6B43aC78BA3 во всех сетях, где он развёрнут; в нём хранятся одобрения токенов для множества приложений. Пользователь один раз одобряет Permit2 в блокчейне, а дальше каждое приложение получает доступ по подписи:

  • PermitSingle / PermitBatch (AllowanceTransfer) выдают allowance (разрешение на списание): PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce), spender и sigDeadline — срок для самой подписи. При amount, равном 2^160 − 1, сумма не ограничена и не уменьшается при переводах; при expiration, равном 2^48 − 1, срок не истекает никогда, а 0 означает «только в этом блоке».
  • PermitTransferFrom / PermitBatchTransferFrom (SignatureTransfer) разрешают один перевод суммы не больше amount до наступления deadline с одноразовым nonce. Они безопаснее: после них ничего не остаётся.
  • Варианты с witness добавляют в ту же подпись структуру приложения, например ордер UniswapX.

В домене Permit2 есть name: "Permit2", chainId и verifyingContract, но нет version. Строки типов выше взяты из файла PermitHash.sol в Permit2.

Как прочитать запрос до подписания

Этот модуль превращает запрос eth_signTypedData_v4 в понятное описание: кто, что и до какого срока может тратить и что выглядит подозрительно. Он распознаёт ERC-2612 и два самых распространённых сообщения Permit2, а обо всём остальном прямо говорит, что формат не распознан.

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

Три запроса, которые может прислать сайт, собраны с ключом, сгенерированным для этого запуска: неограниченный permit USDC на три года в пользу адреса, который пользователь никогда не видел, неограниченный бессрочный allowance в Permit2 на тот же адрес и ограниченный allowance в Permit2, который действительно нужен интерфейсу обмена токенов. Затем первый запрос подписывается, и подпись проверяется с изменёнными доменами:

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)}...`}`);
}
Результат (node:24.22.0-bookworm-slim, 20 октября 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...

Как это читать:

  • Хеш домена USDC равен значению, прочитанному из контракта USDC, а хеш домена Permit2 равен DOMAIN_SEPARATOR контракта Permit2 в основной сети. Запрос, у которого хеш домена отличается от значения в контракте, предназначен для другого контракта или другой сети, что бы ни было написано в его name.
  • Запросы 1 и 2 отдают неизвестному адресу все USDC, которые когда-либо будут у владельца: один на три года, другой навсегда. Срок подписи в запросе 2 — 30 минут; это выглядит безопасно, но это не так: срок ограничивает лишь время, когда подпись можно отправить, а созданный ею allowance бессрочен.
  • Запрос 3 — так выглядит настоящий обмен: известный роутер, 250 USDC (у USDC 6 знаков после запятой, поэтому 250000000 — это 250 единиц), 30 дней.
  • Подпись привязана к своему домену. С другим chain ID или другой версией из неё восстанавливается другой адрес, поэтому там она ничего не стоит.

Подпись вслепую на аппаратных кошельках

Если аппаратный кошелёк не может декодировать сообщение для экрана устройства, он подписывает вслепую. В приложении Ethereum для Ledger давно есть режим, в котором показываются только хеш домена и хеш сообщения (app-ethereum PR #327); для полноценной понятной подписи (clear signing) нужен дескриптор ERC-7730 для этого домена, а без него устройство переходит к подписи вслепую (документация Ledger). Строки domain hash и message hash в выводе выше — именно те значения, которые нужно сравнивать: вычислите их на второй машине по JSON запроса и откажитесь от подписи, если хотя бы одно из них отличается от показанного на устройстве.

Транзакции мультиподписи: случай Safe

Владельцы Safe (ранее Gnosis Safe) тоже одобряют транзакции, подписывая данные EIP-712: структуру SafeTx с адресатом to, value, data, полем operation (0 — обычный вызов, 1 — delegatecall), параметрами газа и nonce в домене из chainId и собственного адреса Safe (EIP712Domain(uint256 chainId,address verifyingContract) начиная с Safe 1.3.0, согласно Safe.sol). Delegatecall выполняет код другого контракта с собственным хранилищем и балансом Safe, поэтому SafeTx с operation: 1 может полностью заменить логику кошелька.

Именно это произошло с биржей Bybit 21 февраля 2025 года. Согласно расследованию Sygnia, код JavaScript, который отдавала собственная веб-инфраструктура Safe{Wallet}, был изменён так, чтобы срабатывать только для холодного кошелька Bybit. Подписанты считали, что одобряют обычный перевод на тёплый кошелёк; подписанная ими транзакция с operation = 1 передавала выполнение заранее развёрнутому контракту, который заменил реализацию кошелька и позволил злоумышленникам вывести более 400 000 ETH без каких-либо дополнительных подписей (хронология Bybit). Интерфейс обманул, а хеши бы не обманули.

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 })}`);
}
Результат (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

Хеш домена во всех трёх случаях одинаков: он говорит только «этот Safe в этой сети». Изменение одного-единственного поля operation меняет хеш сообщения, а значит, и итоговый хеш. Подписант, который пересчитывает safeTxHash по задуманной транзакции на машине без веб-интерфейса кошелька и сравнивает результат с тем, что показывает аппаратное устройство, обнаружит именно такую подмену. Инструмент для расчёта хеша транзакции Safe выполняет это вычисление локально.

Не всякая опасная подпись — это EIP-712

Ещё два формата подписи заслуживают такого же пристального внимания, потому что кошельки показывают их иначе:

  • personal_sign (EIP-191, версия 0x45) добавляет перед сообщением "\x19Ethereum Signed Message:\n" и его длину. Этот формат предназначен для текста, понятного человеку, например сообщения Sign-In with Ethereum. Запрос подписать через personal_sign 32-байтную hex-строку — это просьба подписать то, что вы не можете прочитать. Некоторые легитимные инструменты так делают, в частности мультиподписные кошельки, которые на устройствах без поддержки типизированных данных переходят к подписи хеша транзакции, — и именно в этом случае хеш обязательно нужно пересчитать самостоятельно.
  • Авторизации EIP-7702 (EIP-7702, Final) подписываются по keccak(0x05 ‖ rlp([chain_id, address, nonce])), а не по EIP-712. Одна авторизация записывает в код вашего аккаунта указатель на address, и с этого момента код того контракта выполняется от имени вашего аккаунта: это делегирование всего аккаунта, а не одобрение одного токена. Если chain_id равен 0, авторизация действительна во всех сетях. Предполагается, что кошельки показывают такие подписи особым образом; любой запрос подписать авторизацию вне штатного процесса обновления в самом кошельке считайте враждебным. Декодер EIP-7702 показывает, какому контракту авторизация или транзакция типа 4 делегирует аккаунт.

Как отозвать то, что вы подписали

  • Использованный permit ERC-2612 — это обычный allowance: отзовите его вызовом approve(spender, 0) в контракте токена.
  • Неиспользованный permit ERC-2612 остаётся действительным до своего срока (deadline) или пока не изменится nonce владельца. Использование любого permit для того же токена увеличивает nonce и аннулирует более старые неиспользованные, но прямой «отмены» нет.
  • Allowance в Permit2 можно отозвать вызовом approve(token, spender, 0, 0) или массово через lockdown; неиспользованные подписи PermitSingle аннулируются вызовом invalidateNonces(token, spender, newNonce), а неиспользованные подписи SignatureTransfer — вызовом invalidateUnorderedNonces.
  • Исходное одобрение самому Permit2 сохраняется, пока его не отзовут в контракте токена; именно оно делает ценной каждую подпись Permit2.

Разработчикам, которые формируют типизированные данные

  1. Используйте eth_signTypedData_v4 — версию, которую MetaMask рекомендует для типизированных данных, — и собирайте запрос библиотекой: JSON, собранный вручную, порождает ошибки вроде ambiguous primary types or unused types в ethers и Invalid primary type в viem.
  2. Включайте chainId и verifyingContract в домен. Без них подпись для вашего приложения в одной сети действительна и во всех остальных сетях, где её примет та же логика контракта.
  3. Защищайте каждое подписываемое действие от повторного использования: нужен nonce или набор уже использованных подписей, а также срок действия. В разделе о безопасности EIP-712 прямо сказано, что защита от повторного использования — задача приложения.
  4. Запрашивайте ровно то, что нужно для действия: сумму этой сделки, срок действия в минутах или днях. Интерфейсы, которые просят неограниченные бессрочные одобрения, приучают пользователей их подписывать.
  5. Проверяйте подпись восстановлением адреса, затем сравнивайте адреса, а для кошельков на смарт-контрактах — вместо этого через ERC-1271 isValidSignature; о стороне сервера см. статью Sign-In with Ethereum.

Источники