EIP-712 hashStruct
hashStruct is the core hashing function of EIP-712. It turns a typed struct instance into a 32-byte digest that is type-separated, deterministic, and injective within its type. It is the building block that the full signable-message encoding wraps in a domain separator and an ERC-191 prefix before signing.
The definition
hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))
typeHash = keccak256(encodeType(typeOf(s)))
Two conceptual steps:
typeHash: A compile-time constant per struct type. Derived bykeccak256of the canonical string form of the type (see EIP-712 Typed Data forencodeType).encodeData: The concatenation of encoded member values, each exactly 32 bytes wide.
The final hash is keccak256 of their concatenation.
Why prefix with typeHash
Prefixing encodeData with typeHash means different struct types produce disjoint hash output spaces. This is what allows encodeData to be injective only within a single type — different types are already separated by their different prefixes.
Concretely: it is acceptable for encodeData(a) == encodeData(b) as long as typeOf(a) != typeOf(b). In practice, this matters because structurally identical types (e.g., Transfer(address from,address to,uint256 amount) defined by two different DApps) still produce distinct typeHash if their canonical string forms differ (which they do, at minimum through the EIP712Domain wrapping them).
Why a one-pass hash
The standard deliberately uses a single keccak256 pass over typeHash ‖ encodeData, not a separate hash of encodeData combined later with typeHash. This:
- Preserves injectivity through Solidity’s
keccak256semantics. - Enables an in-place EVM implementation (below).
- Matches the straightforward
abi.encodeidiom.
An alternative considered and rejected was leaving typeHash out of hashStruct and combining it with the domain separator instead. That would have been more efficient but would break the injectivity of Solidity’s keccak256.
Solidity reference implementation
bytes32 constant MAIL_TYPEHASH = keccak256(
"Mail(address from,address to,string contents)"
);
function hashStruct(Mail memory mail) pure returns (bytes32) {
return keccak256(abi.encode(
MAIL_TYPEHASH,
mail.from,
mail.to,
keccak256(mail.contents) // dynamic string: hash the contents
));
}
The only special case the developer has to remember is that dynamic types (bytes, string) are replaced by the hash of their contents, and arrays and nested structs are replaced by their own hashStruct. Everything else falls through to abi.encode.
In-place EVM implementation
EIP-712 also admits an optimized implementation that writes typeHash and pre-computed sub-hashes directly into the struct’s memory layout and hashes it in place:
function hashStruct(Mail memory mail) pure returns (bytes32 hash) {
bytes32 typeHash = MAIL_TYPEHASH;
bytes32 contentsHash = keccak256(mail.contents);
assembly {
// Back up the two words we're going to overwrite
let temp1 := mload(sub(mail, 32))
let temp2 := mload(add(mail, 128))
// Write typeHash just before the struct and contentsHash in place of contents pointer
mstore(sub(mail, 32), typeHash)
mstore(add(mail, 64), contentsHash)
// Hash in place
hash := keccak256(sub(mail, 32), 128)
// Restore memory
mstore(sub(mail, 32), temp1)
mstore(add(mail, 64), temp2)
}
}
This avoids allocating a copy of the encoded data. The implementation makes strong but reasonable assumptions about Solidity memory layout:
- Structs are not allocated below address 32 (so
sub(mail, 32)is valid memory). - Members are stored in declaration order.
- All values are padded to 32-byte boundaries.
- Dynamic and reference types are stored as 32-byte pointers.
Composability and DAGs
hashStruct composes well over tree-shaped data: the hash of a struct is defined recursively in terms of the hashes of its members, so nothing about the whole depends on the path taken to reach a member.
For directed acyclic graphs (shared subgraphs), a naive recursion can visit the same node twice. Memoization is a straightforward optimization — the standard does not require it but admits it.
For cyclical data the standard is undefined. Cycle support would require maintaining a path stack and substituting stack offsets when a cycle is detected, which is prohibitively complex and breaks the compositional property that member hashes construct the struct hash.
Cost
typeHash is a compile-time constant — one keccak256 during compilation, none at runtime. At runtime, hashStruct is one keccak256 per nested struct plus one per dynamic type plus one for the top-level struct itself. For a flat struct with only atomic members, it is a single keccak256 of 32 * (1 + n) bytes.
Connections
- EIP-712: Typed structured data hashing and signing: the parent standard
- EIP-712 Typed Data: the type system that feeds
encodeTypeandencodeData - EIP-712 Domain Separator: the top-level
hashStructcall that wraps every signature