Kurz gesagt. EIP-712 signiert einen Hash strukturierter Daten: keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). Die Domain (Name, Version, Chain-ID, verifizierender Contract) gibt an, für welchen Contract auf welcher Chain die Signatur bestimmt ist; die Nachricht ist ein typisiertes Struct. Lies vor dem Signieren vier Dinge: den verifyingContract und die chainId (ist das der Contract, den du meinst, auf der Chain, auf der du gerade bist?), den primaryType (eine Genehmigung, eine Order, ein Login?), die berechtigte Adresse (Spender) oder den Empfänger sowie die Beträge und Fristen. Eine Nachricht vom Typ Permit nach ERC-2612 oder eine Uniswap-Nachricht für Permit2 mit dem Maximalbetrag und einer weit entfernten Frist ist eine unbegrenzte Genehmigung, die jeder, der die Signatur besitzt, später aktivieren kann. Zeigt deine Hardware-Wallet nur einen Domain-Hash und einen Nachrichten-Hash an, berechne beide auf einem anderen Gerät neu und vergleiche sie.
Was die Bytes bedeuten
EIP-712 (Status „Final“) definiert drei Bausteine:
encodeType: ein Struct-Typ als String,Mail(Person from,Person to,string contents), gefolgt von jedem Struct-Typ, auf den er verweist, nach Namen sortiert:…Person(string name,address wallet). Sein keccak256 ist der Typ-Hash.hashStruct(s) = keccak256(typeHash ‖ encodeData(s)), wobei jedes Feld in 32 Byte kodiert wird: Integer und Adressen als ABI-Wörter,stringundbytesals keccak256 ihres Inhalts, verschachtelte Structs als ihr eigenerhashStruct, Arrays als keccak256 ihrer aneinandergehängten Kodierungen.domainSeparator = hashStruct(EIP712Domain), gebildet über diejenigen der Feldername,version,chainId,verifyingContractundsalt, die die Anwendung verwendet, in dieser Reihenfolge.
Der signierte Digest ist keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)). Das Präfix 0x19 sorgt dafür, dass er niemals eine gültige Transaktion sein kann (RLP beginnt nie mit 0x19), und das Versionsbyte 0x01 grenzt ihn vom Format 0x19 "Ethereum Signed Message" ab, das personal_sign nach EIP-191 verwendet. Die Signatur ist eine gewöhnliche ECDSA-Signatur über secp256k1, 65 Byte einschließlich Recovery-Byte, und aus ihr wird die Adresse des Signierers wiederhergestellt.
Das Beispiel aus der Spezifikation selbst, mit viem nachgerechnet, und zwar nur aus öffentlichen Daten (den Typed Data und den veröffentlichten Werten v, r, s):
// 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 })}`);
typeHash(Mail) 0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail) 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer 0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826
Jeder Wert stimmt mit den Erwartungen in der Datei Example.js des EIP überein, und aus der Signatur wird der Signierer des Beispiels wiederhergestellt.
Was die Domain schützt und was nicht
Die Domain macht eine Signatur für einen Contract auf einer Chain gültig. Dieselbe Nachricht, für Chain 1 signiert, besteht auf Chain 8453 die Prüfung nicht, und eine Änderung von version bei einem Token macht jede noch nicht eingelöste Signatur ungültig. Laut Spezifikation „sollte“ eine Wallet „das Signieren ablehnen“, wenn chainId nicht zur aktiven Chain passt; MetaMask tut das und meldet Provided chainId "1" must match the active chainId "137".
Was die Domain nicht leisten kann: dir sagen, ob der Contract der echte ist. Eine Phishing-Website kann eine Signatur mit name: "USD Coin" und ihrer eigenen Contract-Adresse anfordern; Gewissheit gibt nur der verifyingContract, verglichen mit einer Adresse, der du vertraust. Bei Tokens mit ERC-2612 ist verifyingContract der Token-Contract selbst, bei Permit2 der Permit2-Contract, bei Orders auf Marktplätzen der Contract der Börse.
Die Formate, die Tokens bewegen
ERC-2612 permit (ERC-2612, Status „Final“) ist ein approve per Signatur: Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). Jeder, der die Signatur besitzt, kann sie vor deadline einreichen; das Token setzt dann allowance[owner][spender] = value und erhöht die Nonce des Eigentümers. Der Standard selbst merkt an, dass deadline „auf uint(-1) gesetzt werden kann, um Permits zu erzeugen, die praktisch nie ablaufen“, und ein value von 2^256 − 1 gilt üblicherweise als „unbegrenzt“. Auf der Chain erscheint nichts, bis die Signatur verwendet wird, daher taucht sie in der Genehmigungsliste einer Wallet nicht auf.
Uniswap Permit2 ist ein einzelner Contract, der Token-Genehmigungen für viele Anwendungen verwaltet – auf jeder Chain, auf der er bereitgestellt ist, unter der Adresse 0x000000000022D473030F116dDEE9F6B43aC78BA3. Permit2 wird einmal auf der Chain genehmigt; danach erhält jede Anwendung per Signatur Zugriff:
PermitSingle/PermitBatch(AllowanceTransfer) gewähren eine Allowance:PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce), einenspenderund einesigDeadlinefür die Signatur selbst. Einamountvon2^160 − 1ist unbegrenzt und wird durch Transfers nicht verringert; eineexpirationvon2^48 − 1läuft nie ab, und0bedeutet „nur in diesem Block“.PermitTransferFrom/PermitBatchTransferFrom(SignatureTransfer) autorisieren einen einzigen Transfer von bis zuamountvordeadline, mit einer Einmal-Nonce. Diese Varianten sind sicherer: Danach bleibt nichts bestehen.- Die Witness-Varianten fügen derselben Signatur ein Struct der Anwendung hinzu, etwa eine UniswapX-Order.
Die Domain von Permit2 enthält name: "Permit2", chainId und verifyingContract, aber keine version. Die Typ-Strings oben stammen aus der Datei PermitHash.sol von Permit2.
Eine Anfrage vor dem Signieren lesen
Dieses Modul übersetzt eine Anfrage per eth_signTypedData_v4 in eine Aussage: wer was bis wann ausgeben darf und was verdächtig aussieht. Es erkennt ERC-2612 und die beiden häufigsten Permit2-Nachrichten und sagt bei allem anderen offen, dass es das Format nicht kennt.
// 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");
}
Drei Anfragen, die eine Website senden könnte, gebaut mit einem Schlüssel, der für den Durchlauf erzeugt wurde: ein unbegrenztes USDC-Permit über drei Jahre an eine Adresse, die der Nutzer noch nie gesehen hat, eine unbegrenzte, nie ablaufende Permit2-Allowance an dieselbe Adresse und die begrenzte Permit2-Allowance, die eine Swap-Oberfläche tatsächlich braucht. Danach wird die erste Anfrage signiert, und die Signatur wird unter veränderten Domains ausprobiert:
// 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)}...`}`);
}
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...
So liest du die Ausgabe:
- Der USDC-Domain-Hash entspricht dem Wert, der aus dem USDC-Contract gelesen wurde, und der Permit2-Domain-Hash entspricht dem
DOMAIN_SEPARATORvon Permit2 im Mainnet. Eine Anfrage, deren Domain-Hash von dem des Contracts abweicht, gilt für einen anderen Contract oder eine andere Chain, ganz gleich, was in ihremnamesteht. - Die Anfragen 1 und 2 überlassen einer unbekannten Adresse sämtliche USDC, die der Eigentümer je besitzen wird: die eine für drei Jahre, die andere für immer. Die Signaturfrist von Anfrage 2 beträgt 30 Minuten; das wirkt sicher, ist es aber nicht: Die Frist begrenzt nur, bis wann die Signatur eingereicht werden kann, während die dadurch erzeugte Allowance kein Ende hat.
- Anfrage 3 zeigt, wie ein legitimer Swap aussieht: ein bekannter Router, 250 USDC (USDC hat 6 Dezimalstellen, also entspricht
250000000genau 250 Einheiten), 30 Tage. - Die Signatur ist an ihre Domain gebunden. Unter einer anderen Chain-ID oder Version ergibt sie eine andere Adresse und ist dort deshalb wertlos.
Blind Signing auf Hardware-Wallets
Kann eine Hardware-Wallet eine Nachricht nicht für das Display des Geräts dekodieren, signiert sie blind. Die Ethereum-App von Ledger hat seit Langem einen Modus, der nur den Domain-Hash und den Nachrichten-Hash anzeigt (app-ethereum PR #327); vollständiges Clear Signing braucht einen Deskriptor nach ERC-7730 für die Domain, und ohne ihn fällt das Gerät auf Blind Signing zurück (Ledger-Dokumentation). Die Zeilen domain hash und message hash in der Ausgabe oben sind genau die Werte, die du vergleichen musst: Berechne sie auf einem zweiten Rechner aus dem JSON der Anfrage, und lehne ab, wenn einer von beiden von der Anzeige des Geräts abweicht.
Multisig-Transaktionen: der Fall Safe
Auch die Eigentümer eines Safes (früher Gnosis Safe) genehmigen Transaktionen, indem sie EIP-712-Daten signieren: ein Struct SafeTx mit dem Ziel to, value, data, einem Feld operation (0 für einen Call, 1 für einen Delegatecall), Gas-Parametern und einer nonce, unter einer Domain aus chainId und der eigenen Adresse des Safes (EIP712Domain(uint256 chainId,address verifyingContract) seit Safe 1.3.0, laut Safe.sol). Ein Delegatecall führt den Code eines anderen Contracts mit dem Speicher und dem Guthaben des Safes aus, daher kann ein SafeTx mit operation: 1 die Logik der Wallet vollständig ersetzen.
Genau das ist der Börse Bybit am 21. Februar 2025 passiert. Laut der Untersuchung von Sygnia war JavaScript, das von der eigenen Web-Infrastruktur von Safe{Wallet} ausgeliefert wurde, so verändert worden, dass es nur bei der Cold Wallet von Bybit aktiv wurde. Die Signierer glaubten, einen routinemäßigen Transfer an eine Warm Wallet zu genehmigen; die Transaktion, die sie signierten, nutzte operation = 1, um die Ausführung an einen vorab bereitgestellten Contract zu delegieren, der die Implementierung der Wallet ersetzte und es den Angreifern erlaubte, ohne weitere Signaturen über 400.000 ETH abzuziehen (Zeitleiste von Bybit). Die Oberfläche hat gelogen; die Hashes hätten es nicht getan.
// 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 })}`);
}
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
Der Domain-Hash ist in allen drei Fällen identisch: Er sagt nur „dieser Safe auf dieser Chain“. Wird das einzelne Feld operation umgestellt, ändern sich der Nachrichten-Hash und damit auch der finale Hash. Ein Signierer, der safeTxHash aus der beabsichtigten Transaktion auf einem Rechner neu berechnet, auf dem die Weboberfläche der Wallet nicht läuft, und das Ergebnis mit der Anzeige des Hardware-Geräts vergleicht, erkennt genau diese Art von Austausch. Der Safe-Transaktions-Hash-Rechner führt diese Berechnung lokal aus.
Nicht jede gefährliche Signatur ist EIP-712
Zwei weitere Signaturformate verdienen dieselbe Aufmerksamkeit, weil Wallets sie anders anzeigen:
personal_sign(EIP-191 Version0x45) stellt der Nachricht"\x19Ethereum Signed Message:\n"und ihre Länge voran. Das Format ist für menschenlesbaren Text gedacht, etwa eine Nachricht für Sign-In with Ethereum. Eine Anfrage, einen 32-Byte-Hex-String perpersonal_signzu signieren, verlangt von dir, etwas zu signieren, das du nicht lesen kannst. Manche legitimen Tools tun das, vor allem Multisig-Wallets, die auf Geräten ohne Unterstützung für Typed Data darauf ausweichen, einen Transaktions-Hash zu signieren – und genau dann musst du den Hash selbst neu berechnen.- EIP-7702-Autorisierungen (EIP-7702, Status „Final“) werden über
keccak(0x05 ‖ rlp([chain_id, address, nonce]))signiert, nicht nach EIP-712. Eine Autorisierung setzt den Code deines Kontos auf einen Zeiger aufaddress, sodass der Code dieses Contracts von da an als dein Konto ausgeführt wird: Das ist eine Delegierung des gesamten Kontos, keine Genehmigung für ein einzelnes Token. Einechain_idvon 0 macht die Autorisierung auf jeder Chain gültig. Von Wallets wird erwartet, dass sie solche Autorisierungen gesondert anzeigen; behandle jede Anfrage, eine davon außerhalb des eigenen Upgrade-Ablaufs deiner Wallet zu signieren, als feindselig. Der EIP-7702-Decoder zeigt, an wen eine Autorisierung oder eine Transaktion vom Typ 4 delegiert.
Widerrufen, was du signiert hast
- Ein verwendetes ERC-2612-Permit ist eine gewöhnliche Allowance: Widerrufe sie mit
approve(spender, 0)auf dem Token. - Ein nicht verwendetes ERC-2612-Permit bleibt gültig, bis seine Frist abläuft oder sich die Nonce des Eigentümers ändert. Wird irgendein Permit für dasselbe Token verwendet, erhöht das die Nonce und entwertet ältere, nicht verwendete Permits, aber eine direkte Möglichkeit zum Stornieren gibt es nicht.
- Permit2-Allowances lassen sich mit
approve(token, spender, 0, 0)widerrufen oder gesammelt mitlockdown; nicht verwendete Signaturen fürPermitSinglewerden durchinvalidateNonces(token, spender, newNonce)ungültig, nicht verwendete SignatureTransfer-Signaturen durchinvalidateUnorderedNonces. - Die zugrunde liegende Genehmigung für Permit2 selbst bleibt bestehen, bis sie auf dem Token widerrufen wird; erst diese Genehmigung macht jede Permit2-Signatur wertvoll.
Wenn du selbst Typed Data erzeugst
- Verwende
eth_signTypedData_v4, die Version, die MetaMask für Typed Data empfiehlt, und eine Bibliothek, die die Daten aufbaut: Von Hand zusammengesetztes JSON ist die Quelle von Fehlern wie ambiguous primary types or unused types in ethers und Invalid primary type in viem. - Nimm
chainIdundverifyingContractin deine Domain auf. Ohne sie ist eine Signatur für deine App auf einer Chain auch auf jeder anderen Chain gültig, auf der dieselbe Contract-Logik sie akzeptiert. - Mach jede signierte Aktion replay-sicher: mit einer Nonce oder einer Menge bereits verwendeter Signaturen und mit einer Frist. Der Sicherheitsabschnitt von EIP-712 stellt ausdrücklich klar, dass der Replay-Schutz Aufgabe der Anwendung ist.
- Fordere genau das an, was die Aktion braucht: den Betrag dieses Trades, eine Gültigkeitsdauer in Minuten oder Tagen. Oberflächen, die unbegrenzte, nie ablaufende Genehmigungen anfordern, gewöhnen die Nutzer daran, solche Genehmigungen zu signieren.
- Prüfe per Wiederherstellung der Adresse und vergleiche dann die Adressen, bei Smart-Contract-Wallets stattdessen mit
isValidSignaturenach ERC-1271; die Serverseite beschreibt Sign-In with Ethereum.
Quellen
- EIP-712: Typed structured data hashing and signing und die zugehörige Datei Example.js
- EIP-191: Signed Data Standard
- ERC-2612: Permit Extension for EIP-20 Signed Approvals
- Uniswap Permit2:
PermitHash.sol,AllowanceTransfer.sol,SignatureTransfer.sol,libraries/Allowance.sol - MetaMask: Daten signieren
- Ledger: Clear Signing für Wallets und ERC-7730-Registry
- viem:
hashTypedData,hashDomain,hashStruct,recoverTypedDataAddress