EIP-712: Typed structured data hashing and signing

source
ethereumeipsignaturescryptographyoff-chaindappwallet

EIP-712 is the Ethereum standard for hashing and signing typed structured data. It replaced opaque eth_sign hex strings with structured, human-readable, domain-scoped signatures. Authored by Remco Bloemen, Leonid Logvinov, and Jacob Evans; created 2017-09-12. Standards Track, Interface category. Requires EIP-155 and EIP-191.

Motivation

Signing raw bytestrings is a solved problem. Signing structured messages is not — and hashing them incorrectly erases the security properties of the system. Before EIP-712, users were shown messages like 0x19457468657265756d205369676e6564... and asked to sign blindly. This defeats the purpose of asking for consent. EIP-712 encodes both the data and its structure so that wallets can render field names and values in a verification UI before the user signs.

The extended signable message set

The set of signable messages is extended from transactions and bytestrings 𝕋 ∪ 𝔹⁸ⁿ to also include structured data 𝕊:

  • encode(transaction : 𝕋) = RLP_encode(transaction)
  • encode(message : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message
  • encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message)

The encoding is deterministic (each component is) and injective (the three cases always differ in the first byte, and RLP_encode(transaction) never starts with \x19). The encoding is EIP-191 compliant with version byte 0x01, domain separator as version-specific data, and hashStruct(message) as the data to sign.

See Ethereum Signed Message Encoding for the full leading-byte disambiguation scheme.

Typed structured data (𝕊)

The type system is modeled on Solidity structs. Example:

struct Mail {
    address from;
    address to;
    string contents;
}

Three categories of member types:

  • Atomic types: bytes1bytes32, uint8uint256, int8int256, bool, address. No uint/int aliases. No fixed-point numbers.
  • Dynamic types: bytes, string. Declared like atomic types but encoded differently.
  • Reference types: Arrays (Type[] or Type[n]) and other structs. Recursive struct types are supported; cyclical data is undefined.

See EIP-712 Typed Data for the full type grammar.

hashStruct

The core hashing function:

hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))
typeHash = keccak256(encodeType(typeOf(s)))

typeHash is a compile-time constant per type. Because it prefixes encodeData, encodeData only needs to be injective within a single type — different types are already separated by their different typeHash.

Full derivation and alternative encodings considered are covered in EIP-712 hashStruct.

encodeType

A struct type is encoded as Name(type1 name1,type2 name2,...,typeN nameN). Example: Mail(address from,address to,string contents).

When a struct references other struct types, the set of referenced types is collected, sorted alphabetically by name, and appended to the encoding:

Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)

encodeData

Concatenation of encoded member values, each exactly 32 bytes:

  • Booleans encoded as uint256 0 or 1
  • Addresses encoded as uint160
  • Integers sign-extended to 256 bits, big-endian
  • bytes1bytes31 zero-padded at the end to bytes32
  • Dynamic bytes and string encoded as keccak256 of their contents
  • Arrays encoded as keccak256 of concatenated encodeData of their elements
  • Nested structs encoded recursively as hashStruct(value)

This matches abi.encode closely enough that a Solidity implementation is a one-liner:

function hashStruct(Mail memory mail) pure returns (bytes32) {
    return keccak256(abi.encode(
        MAIL_TYPEHASH,
        mail.from,
        mail.to,
        keccak256(mail.contents)
    ));
}

It also admits an in-place EVM implementation that writes typeHash and sub-hashes into the struct’s own memory layout and hashes in place, backing up and restoring the surrounding words.

Domain separator

domainSeparator = hashStruct(eip712Domain)

Where EIP712Domain is a struct with one or more of:

  • string name — the user-readable name of the signing domain (DApp or protocol name)
  • string version — the major version; signatures from different versions are incompatible
  • uint256 chainId — the EIP-155 chain ID; wallets should refuse signing if it does not match the active chain
  • address verifyingContract — the contract that will verify the signature; wallets may apply contract-specific phishing prevention
  • bytes32 salt — a disambiguating salt of last resort

Protocol designers include only the fields that make sense for their domain. Fields must appear in the order above (with absences skipped); future field additions must be alphabetical and come after these. See EIP-712 Domain Separator for why this matters and the canonical patterns.

eth_signTypedData JSON-RPC

The wallet RPC added by EIP-712:

sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)))

Parameters:

  1. Address — 20 bytes, the signing account (must be unlocked)
  2. TypedData — JSON object with types, primaryType, domain, message

JSON schema (simplified):

{
  "types": {
    "EIP712Domain": [...],
    "CustomStruct": [...]
  },
  "primaryType": "CustomStruct",
  "domain": { "name": "...", "version": "...", "chainId": 1, "verifyingContract": "0x..." },
  "message": { ... }
}

Returns a 65-byte hex signature in (r, s, v) big-endian order. The v parameter includes the EIP-155 chain ID.

personal_signTypedData takes an additional password argument. web3.eth.signTypedData(typedData, address) and web3.eth.personal.signTypedData(typedData, address, password) expose the same operation in Web3.js.

Security considerations

EIP-712 is a hashing and signing standard only. Two application-level concerns it explicitly does not address:

Replay attacks: Signed messages can be submitted twice unless the application rejects duplicates or makes the authorized action idempotent. Nonces are the standard mitigation. See Off-Chain Signatures for replay protection patterns.

Frontrunning: An attacker who intercepts a signature can submit it to the contract before the intended use. The application must either reject the out-of-order submission or produce the same effect regardless.

Backwards compatibility

keccak256(someInstance) in Solidity, when someInstance is a struct, currently evaluates to the hash of the memory address of the instance. EIP-712 considers this behavior dangerously broken — it appears correct in some scenarios and silently fails determinism in others. DApps depending on it are broken.

Connections