https://eips.ethereum.org/EIPS/eip-191 It’s commonly used for: - Off-chain message signing: For example, signing orders for decentralized exchanges (DEXs) or approvals for gasless transactions. - Gasless transactions: Allowing users to sign messages that smart contracts can verify and execute on their behalf (e.g., meta-transactions). - Cross-chain or multi-contract interactions: Ensuring signatures are valid only in specific contexts. EIP-712 introduces a structured format for data, a standardized hashing mechanism, and a way to bind signatures to a specific "domain" (e.g., a smart contract or chain). The key components are: - Typed data: Structured data defined by structs (e.g., Message or Order). - Domain separator: A hash representing the signing context (e.g., contract, chain). - Signature: The cryptographic signature (v, r, s) produced by signing a hash of the typed data and domain. The process involves multiple hashing steps, including hashing the EIP712Domain struct, its type definition, and combining these with the message hash. Let’s explore why this is done and how it fits into the broader picture. --- The Big Picture: Why All This Hashing? The EIP-712 hashing process is designed to achieve three primary goals: 1. Security: Prevent replay attacks, signature collisions, and unauthorized reuse of signatures. 2. Context Specificity: Ensure signatures are valid only for the intended contract, chain, or application. 3. Standardization: Provide a consistent, verifiable way to encode and sign structured data across Ethereum-compatible systems. The hashing steps—particularly hashing the EIP712Domain struct type, creating the domain separator, and combining it with the message hash—are critical to achieving these goals. Here’s the step-by-step process and the reasoning behind each component. --- Step-by-Step Breakdown of the EIP-712 Hashing Process 1. Defining the Structs EIP-712 operates on structured data defined as structs. Two key structs are typically involved: - EIP712Domain: Defines the signing context. solidity ```solidity struct EIP712Domain { string name; // Name of the dApp or protocol string version; // Version of the signing domain uint256 chainId; // Ethereum network ID (e.g., 1 for mainnet) address verifyingContract; // Address of the contract verifying the signature bytes32 salt; // Optional: Additional entropy for uniqueness } ``` - Custom Struct: Represents the message or data being signed (e.g., Order, Transfer). solidity ```solidity struct Order { address buyer; uint256 amount; uint256 nonce; } ``` Each struct has a type definition, a string that describes its structure, including field names and types (e.g., "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"). 2. Computing the Type Hash For every struct, EIP-712 computes a type hash, which is the keccak256 hash of the struct’s type definition string. Why do we need the type hash? - Uniquely identifies the struct’s schema: The type hash is a deterministic fingerprint of the struct’s structure (field names, types, and order). For example: solidity ```solidity typeHash = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); ``` This ensures that two structs with different definitions (e.g., Order(uint256 amount) vs. Trade(uint256 amount)) produce different type hashes, preventing confusion or collisions. - Prevents signature reuse: If two structs have the same field values but different schemas, their type hashes ensure their hashes (and thus signatures) are distinct. - Supports nested structs: For complex data with nested structs, the type hash of a parent struct includes the type hashes of its child structs, ensuring recursive encoding is consistent. - Enables verification: The verifier recomputes the type hash to ensure the signed data matches the expected struct definition. Example: For EIP712Domain, the type hash is computed as: solidity ```solidity bytes32 typeHash = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); ``` This type hash is used in the next step to hash the EIP712Domain struct’s values. 3. Creating the Domain Separator The domain separator is a hash that encapsulates the EIP712Domain struct’s context. It is computed by: 1. Encoding the type hash of EIP712Domain with its field values using ABI encoding. 2. Hashing the result with keccak256. Formula: solidity ```solidity domainSeparator = keccak256(abi.encode( typeHash, keccak256(bytes(name)), keccak256(bytes(version)), chainId, verifyingContract )); ``` Why hash the EIP712Domain separately? - Isolates the signing context: The domain separator uniquely represents the context (e.g., dApp, contract, chain) in which the signature is valid. This prevents a signature meant for one contract or chain from being reused in another. - Prevents replay attacks: - The chainId ensures signatures are chain-specific (e.g., a signature for Ethereum mainnet cannot be reused on Polygon). - The verifyingContract ensures signatures are contract-specific (e.g., a signature for Contract A cannot be used for Contract B). - The name and version prevent misuse across different dApps or protocol versions. - The optional salt adds extra uniqueness if needed. - Standardizes context encoding: By hashing the domain separately, EIP-712 ensures the context is consistently included in the final signature hash, making it verifiable and secure. Why include the type hash in the domain separator? - The type hash ensures the domain separator is tied to the specific EIP712Domain struct definition. If the struct’s schema changes (e.g., adding a new field), the type hash changes, producing a different domain separator and preventing signature reuse. 4. Hashing the Message (Custom Struct) The message (e.g., an Order struct) is hashed similarly: 1. Compute the type hash of the message struct: solidity ```solidity bytes32 orderTypeHash = keccak256("Order(address buyer,uint256 amount,uint256 nonce)"); ``` 2. Encode the type hash with the message’s field values and hash the result: solidity ```solidity bytes32 messageHash = keccak256(abi.encode( orderTypeHash, buyer, amount, nonce )); ``` Why hash the message with its type hash? - Same reasons as for the domain: The type hash ensures the message hash is unique to the struct’s schema, preventing collisions and ensuring the signature is tied to the exact data structure. 5. Combining Domain Separator and Message Hash The final hash to be signed is computed by combining the domain separator and message hash, prefixed with \x19\x01 (per EIP-191): solidity ```solidity bytes32 finalHash = keccak256(abi.encodePacked( "\x19\x01", domainSeparator, messageHash )); ``` Why hash again with the domain separator and message? - Binds the message to its context: Including the domain separator ensures the signature is only valid for the specific domain (contract, chain, etc.). Without this, a signature could be reused across different contexts. - Prevents collisions: The domain separator adds uniqueness, ensuring that two identical messages signed for different domains produce different final hashes. - EIP-191 compliance: The \x19\x01 prefix is an Ethereum convention for signed messages, ensuring the hash is Ethereum-compatible and distinguishable from other types of signed data (e.g., raw transactions). - Creates a single hash for signing: The final hash combines all relevant information (domain context and message content) into a single value that can be signed with a private key to produce v, r, and s. 6. Signing the Final Hash The finalHash is signed using the signer’s private key, producing the signature components v, r, and s. These are used by the verifying contract to validate the signature. Verification: - The verifier (e.g., a smart contract) recomputes the finalHash using the same process (type hashes, domain separator, message hash). - It then checks if the signature (v, r, s) corresponds to the expected signer’s public key using ecrecover. --- The Whole Picture: Why This Design? The multi-step hashing process—computing type hashes, creating a domain separator, and combining it with the message hash—serves several critical purposes: 1. Security Against Replay Attacks - The domain separator (via chainId, verifyingContract, etc.) ensures signatures are only valid in their intended context. For example: - A signature for a DEX on Ethereum mainnet cannot be reused on a testnet. - A signature for one smart contract cannot be reused for another. - This is vital for applications like gasless transactions, where signatures are processed off-chain and submitted on-chain. 2. Collision Resistance - The type hash ensures that structs with different schemas produce different hashes, even if their field values are identical. This prevents a signature for one struct from being misused for another. - The domain separator ensures that identical messages signed for different domains produce different final hashes. 3. Context-Specific Signatures - By separating the domain and message hashing, EIP-712 ensures signatures are tightly bound to their intended environment (dApp, contract, chain). This is crucial for decentralized systems where multiple contracts or networks coexist. 4. Standardization and Interoperability - The use of type hashes, ABI encoding, and the \x19\x01 prefix ensures that EIP-712 signatures are consistent across Ethereum-compatible systems. This allows wallets (e.g., MetaMask), dApps, and contracts to implement EIP-712 uniformly. - The structured format also makes it easier for wallets to display human-readable data to users before signing (e.g., "Approve a trade of 100 tokens"). 5. Support for Complex Data - The type hash mechanism supports nested structs, enabling EIP-712 to handle arbitrarily complex data structures. This is essential for real-world use cases like decentralized finance (DeFi) protocols or NFT marketplaces. 6. Human-Readable Signing - EIP-712 allows wallets to display the structured data (e.g., field names and values) to users before signing, improving user experience and reducing the risk of signing malicious or unclear data. --- Tying It Back to Your Questions 1. Why hash the EIP712Domain struct type before hashing with the message, v, r, s? - Hashing the EIP712Domain creates the domain separator, which isolates the signing context (contract, chain, etc.). This is combined with the message hash to ensure the signature is context-specific and resistant to replay attacks. The v, r, s values are the signature of the final hash, not directly hashed with it, but verified against it. 2. Why do we need the keccak256 hash of the struct’s type definition? - The type hash uniquely identifies the struct’s schema, ensuring that the hash of the data reflects both its structure and values. This prevents collisions, supports nested structs, and ensures signatures are tied to the exact data definition. 3. Why multiple hashing steps? - Each hashing step serves a purpose: - Type hash: Encodes the struct’s schema. - Domain separator: Encodes the signing context. - Message hash: Encodes the data being signed. - Final hash: Combines context and data for signing, ensuring uniqueness and security. --- Practical Example Suppose a DEX uses EIP-712 to sign an order: - Domain: solidity ```solidity EIP712Domain { name: "MyDEX", version: "1", chainId: 1, verifyingContract: 0x1234... } ``` - Message: solidity ```solidity Order { buyer: 0x5678..., amount: 100, nonce: 42 } ``` Process: 1. Compute type hashes: solidity ```solidity domainTypeHash = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); orderTypeHash = keccak256("Order(address buyer,uint256 amount,uint256 nonce)"); ``` 2. Compute domain separator: solidity ```solidity domainSeparator = keccak256(abi.encode( domainTypeHash, keccak256("MyDEX"), keccak256("1"), 1, 0x1234... )); ``` 3. Compute message hash: solidity ```solidity messageHash = keccak256(abi.encode( orderTypeHash, 0x5678..., 100, 42 )); ``` 4. Compute final hash: solidity ```solidity finalHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, messageHash)); ``` 5. Sign finalHash to get v, r, s. 6. The DEX contract verifies the signature by recomputing finalHash and checking if ecrecover returns the expected signer. Outcome: - The signature is only valid for MyDEX on Ethereum mainnet, for contract 0x1234..., and for the specific Order struct. Any attempt to reuse it elsewhere fails due to the domain separator and type hashes. --- Conclusion The EIP-712 hashing process, with its type hashes, domain separator, and final hash, is a carefully designed system to ensure secure, context-specific, and standardized signing of structured data. The keccak256 hash of the struct’s type definition is critical for uniquely identifying data schemas, preventing collisions, and supporting complex data. Hashing the EIP712Domain separately creates a domain separator that binds signatures to their intended context, while the final hash combines everything for signing. This multi-step approach makes EIP-712 robust for use cases like DeFi, NFTs, and gasless transactions, ensuring signatures are secure, verifiable, and interoperable across the Ethereum ecosystem.