Bei EIP-191 vs. EIP-712 geht es um die Wahl zwischen den beiden Signaturformaten von Ethereum für Daten, die keine Transaktion sind. EIP-191 (2016) definiert ein Präfix 0x19, mit dem keine gültige Transaktionskodierung jemals beginnen kann, und dahinter ein Versionsbyte, das das Format auswählt; Version 0x45 ist personal_sign für menschenlesbaren Text. EIP-712 (2017) ist Version 0x01: Es hasht typisierte, strukturierte Daten zusammen mit einem Domain Separator, der die Anwendung, die Chain und den prüfenden Contract benennt. EIP-712 ist also ein bestimmtes Format innerhalb von EIP-191 und keine Konkurrenz zum gesamten Standard.
Auf einen Blick
EIP-191 personal_sign (Version 0x45) | EIP-712 Typed Data (Version 0x01) | |
|---|---|---|
| Status | Final, erstellt am 20. Januar 2016 | Final, erstellt am 12. September 2017 |
| RPC-Methode der Wallet | personal_sign | eth_signTypedData_v4 |
| Mit Keccak-256 gehashte Bytes | 0x19 "Ethereum Signed Message:\n" Länge Nachricht | 0x19 0x01 domainSeparator hashStruct(message) |
| Was die Wallet anzeigt | Den Text oder rohes Hex, wenn die Nachricht binär ist | Feldnamen und Werte sowie die Domain (Name, Chain, Contract) |
| Replay-Schutz | Nicht eingebaut; der Nachrichtentext muss Nonce, Domain und Chain enthalten | Domain Separator mit name, version, chainId, verifyingContract, salt |
| Prüfung im Contract | Nachricht mit Präfix nachbauen, dann ecrecover | Type-Hashes und Struct-Hashes neu berechnen, dann ecrecover |
| Typische Einsätze | Sign-In with Ethereum (EIP-4361), Nachweis, dass eine Adresse dir gehört | Permit (EIP-2612), Permit2, Safe-Transaktionen, Börsen-Orders, Governance-Abstimmungen |
| Signatur | 65 Byte, ECDSA auf der Kurve secp256k1, r, s, v | Dieselbe |
| Häufigste Fallstricke | Blind signierte Hex-Hashes; mehrdeutiger Freitext | Harmlos wirkende Genehmigungen; Geräte, die nur einen Hash anzeigen |
Was jeweils gehasht wird
Version 0x45 von EIP-191 setzt "\x19Ethereum Signed Message:\n" und die Länge der Nachricht als Dezimalzahl in ASCII vor die Nachricht und hasht das Ergebnis mit Keccak-256. Das Präfix garantiert, dass die signierten Bytes keine RLP-kodierte Transaktion sein können; eine Website kann eine Wallet also nicht dazu bringen, eine Überweisung zu signieren, indem sie diese als Nachricht ausgibt. Mehr leistet es nicht: Ob die Signatur „bei example.com anmelden“ oder „unbegrenzte Ausgaben genehmigen“ bedeutet, hängt allein davon ab, wie der Empfänger den Text auslegt.
EIP-712 kodiert jedes Struct als Hash seines Typ-Strings, gefolgt von 32-Byte-Wörtern für seine Felder: Strings und Bytes werden gehasht, Adressen und Ganzzahlen links mit Nullen aufgefüllt, verschachtelte Structs durch ihre eigenen Hashes ersetzt. Die Domain wird auf dieselbe Weise als Struct EIP712Domain kodiert. Der endgültige Digest ist keccak256(0x19 0x01 domainSeparator hashStruct(message)).
Das folgende Skript berechnet beide mit @noble/curves 2.3.0 und @noble/hashes 2.3.0 unter Node.js 26 und verwendet dabei die Nachricht „Ether Mail“ und den Signaturschlüssel aus dem Referenzbeispiel Example.js von EIP-712. Dieser Schlüssel, keccak256("cow"), ist in der Spezifikation als Testvektor veröffentlicht: Jeder kann ihn ableiten, deshalb darf auf seiner Adresse nie Guthaben liegen.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { keccak_256 } from "@noble/hashes/sha3.js";
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
const hex = (b) => "0x" + bytesToHex(b);
// Test key published in the EIP-712 reference example (assets/eip-712/Example.js): keccak256("cow").
// It is public, so never send funds to its address.
const secretKey = keccak_256(utf8ToBytes("cow"));
const signer = hex(keccak_256(secp256k1.getPublicKey(secretKey, false).subarray(1)).subarray(12));
const sign = (digest) => {
const sig = secp256k1.sign(digest, secretKey, { prehash: false, format: "recovered" });
return { v: sig[0] + 27, r: hex(sig.subarray(1, 33)), s: hex(sig.subarray(33)) };
};
// EIP-191 version 0x45: personal_sign
const text = utf8ToBytes("hello");
const personalDigest = keccak_256(concatBytes(utf8ToBytes(`\x19Ethereum Signed Message:\n${text.length}`), text));
console.log("EIP-191 personal_sign digest:", hex(personalDigest));
// EIP-712: typed structured data (the "Ether Mail" example from the EIP)
const types = {
EIP712Domain: [["name", "string"], ["version", "string"], ["chainId", "uint256"], ["verifyingContract", "address"]],
Person: [["name", "string"], ["wallet", "address"]],
Mail: [["from", "Person"], ["to", "Person"], ["contents", "string"]],
};
const encodeType = (primary) => {
const deps = new Set();
const walk = (t) => types[t].forEach(([, ft]) => { if (types[ft] && !deps.has(ft)) { deps.add(ft); walk(ft); } });
walk(primary);
return [primary, ...[...deps].sort()].map((t) => `${t}(${types[t].map(([n, ft]) => `${ft} ${n}`).join(",")})`).join("");
};
const encodeValue = (ft, v) =>
types[ft] ? hashStruct(ft, v)
: ft === "string" ? keccak_256(utf8ToBytes(v))
: ft === "address" ? concatBytes(new Uint8Array(12), hexToBytes(v.slice(2)))
: hexToBytes(BigInt(v).toString(16).padStart(64, "0")); // uint256
const hashStruct = (t, v) =>
keccak_256(concatBytes(keccak_256(utf8ToBytes(encodeType(t))), ...types[t].map(([n, ft]) => encodeValue(ft, v[n]))));
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const mail = {
from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
contents: "Hello, Bob!",
};
const typedDigest = (d) => keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), hashStruct("EIP712Domain", d), hashStruct("Mail", mail)));
console.log("encodeType(Mail):", encodeType("Mail"));
console.log("domainSeparator:", hex(hashStruct("EIP712Domain", domain)));
console.log("hashStruct(Mail):", hex(hashStruct("Mail", mail)));
console.log("EIP-712 digest:", hex(typedDigest(domain)));
console.log("signer:", signer, sign(typedDigest(domain)));
console.log("same mail, chainId 10:", hex(typedDigest({ ...domain, chainId: 10 })));
EIP-191 personal_sign digest: 0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750
encodeType(Mail): Mail(Person from,Person to,string contents)Person(string name,address wallet)
domainSeparator: 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
hashStruct(Mail): 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
EIP-712 digest: 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer: 0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826 {
v: 28,
r: '0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d',
s: '0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562'
}
same mail, chainId 10: 0x2752db86097e9749484b4645855d9ff3d1b6109c89ed55a9d7f9021dbc77bcf9
Typ-String, Domain Separator, Struct-Hash, Digest, Adresse des Signierers sowie v, r, s sind genau die Werte, die das Referenzbeispiel Example.js von EIP-712 prüft, und die Signatur ist deterministisch (RFC 6979), du kannst sie also reproduzieren. Wird nur chainId geändert, entsteht ein anderer Digest – genau das verhindert Replays über Chains hinweg. Der EIP-712-Inspektor zeigt dieselben Zwischen-Hashes für jede Typed-Data-Payload.
Was Nutzer tatsächlich sehen
Der Hauptvorteil von EIP-712 liegt in der Anzeige: Eine Wallet kann „spender“, „value“ und „deadline“ zeigen statt eines undurchsichtigen Strings. Das hilft nur, wenn das Gerät die Felder darstellen kann und der Nutzer sie liest. Die meisten Phishing-Angriffe, die Wallets leerräumen, verlangen heute EIP-712-Signaturen, die vollständig gültig sind, etwa ein Permit (EIP-2612) oder eine Permit2-Genehmigung, die einer berechtigten Adresse (Spender) unbegrenzt viele Tokens zugesteht – ohne Transaktion und ohne Gas, sodass der Nutzer keine On-Chain-Warnung sieht. Im Februar 2025 genehmigten drei Signierer von Bybit auf Hardware-Wallets, die nur den Hash anzeigen konnten, eine bösartige Safe-Transaktion; die Transaktion ersetzte die Implementierung der Wallet, und ETH im Wert von etwa 1,5 Milliarden Dollar wurde abgezogen. Wer einen Safe-Transaktions-Hash unabhängig nachrechnet, wie es das Tool für Safe-Transaktions-Hashes tut, kann als Signierer prüfen, was ein Gerät als Hex anzeigt.
personal_sign hat die spiegelbildliche Schwäche: Wallets zeigen Text originalgetreu an, aber eine Website kann dazu auffordern, einen 32-Byte-Hex-String zu signieren, der nichts verrät. Das Signieren roher Hashes ohne jedes Präfix (eth_sign) wurde in MetaMask deshalb 2023 standardmäßig deaktiviert und 2024 entfernt.
Signaturen prüfen
Bei Konten mit eigenem Schlüssel (Externally Owned Accounts) prüfst du beide Formate, indem du den Digest neu berechnest, den Signierer mit ecrecover (oder dem Gegenstück einer Bibliothek) wiederherstellst und dann die Adresse vergleichst; lehne Signaturen mit hohem s ab, um Malleability zu vermeiden. Smart-Contract-Wallets (Safe, Konten nach ERC-4337) können keine eigenen ECDSA-Signaturen erzeugen, deshalb müssen Verifizierer ERC-1271 isValidSignature(hash, signature) aufrufen und den magischen Rückgabewert 0x1626ba7e akzeptieren; ERC-6492 erweitert das auf Wallets, die noch nicht bereitgestellt sind. Der Ethereum-Signatur-Verifizierer und der SIWE-Verifizierer decken beide Fälle ab.
Wann welches Format passt
- Anmeldung und Nachweis, dass eine Adresse dir gehört: EIP-191 mit einer Nachricht nach EIP-4361 (SIWE), die Domain, URI, Chain-ID, Nonce und Ablaufzeit enthält.
- Token-Genehmigungen ohne Transaktion: EIP-712-Permit oder Permit2, mit kurzen Fristen.
- Orders, Abstimmungen, Meta-Transaktionen, Attestierungen: EIP-712 mit einer Domain, die
chainIdundverifyingContractenthält. - Multisig-Transaktionen: EIP-712, wie Safe es verwendet, plus unabhängige Prüfung des Hashes auf einem zweiten Gerät.
- Freitext, den ein Mensch lesen soll: EIP-191.
- Niemals:
personal_signüber einen Hash oder EIP-712-Domains ohne Chain-ID.
Hinweise zur Migration
- Umstellung einer Off-Chain-Autorisierung von
personal_signauf EIP-712: Definiere das Struct, nimm Felder für Nonce und Frist auf und akzeptiere das alte Format nur so lange, bis die noch ausstehenden Signaturen abgelaufen sind. - Versioniere die Domain: Erhöhe
version, wenn sich die Bedeutung von Feldern ändert, damit alte Signaturen nicht nach neuen Regeln gelesen werden können. - Prüfe mit derselben Bibliothek, mit der du signierst, und teste gegen die oben gezeigten Referenzvektoren von EIP-712.
- Unterstütze Smart-Contract-Wallets über ERC-1271 und ERC-6492, statt
ecrecovervorauszusetzen. - Widerrufen und ablaufen lassen: Gib Nutzern eine Möglichkeit, Permits zu widerrufen, und verwende kurze Fristen; Signaturen ohne Ablaufdatum bleiben unbegrenzt verwendbar.
Quellen
- ERC-191, Standard für signierte Daten
- EIP-712, Hashing und Signieren typisierter strukturierter Daten und Referenzbeispiel Example.js
- ERC-2612, Permit
- ERC-4361, Standard für Sign-In with Ethereum
- ERC-1271, Standard zur Signaturprüfung für Contracts und ERC-6492
- MetaMask MIP-3, Einstellung von eth_sign