EIP-712 signs a typed message within a domain. The wallet receives field names, types, and values, then encodes two structures: the message and its domain. Their hashes become the inputs to one final signing digest.

Give bytes a type

Start with a message small enough to inspect:

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

The schema becomes a canonical string. Field order is preserved; there are no spaces around commas.

Mail(address from,address to,string contents)

Hashing this string produces typeHash. The type name, field names, and field types all participate. Renaming contents changes the type hash even if the value stays the same.

For a structure s, EIP-712 defines:

hashStruct⁡(s)=keccak256⁡(typeHash⁡∥encodeData⁡(s))\operatorname{hashStruct}(s) = \operatorname{keccak256}(\operatorname{typeHash} \mathbin{\|} \operatorname{encodeData}(s))

Every encoded field occupies 32 bytes. An address is left-padded with zeroes. Integers use a 256-bit word, with sign extension for signed values. A string contributes the Keccak-256 ⁠ hash of its UTF-8 bytes.

For this Mail, the preimage is four words:

WordSourceEncoding · 32 bytes each
0Mail schematypeHash
1from12 zero bytes + 20-byte address
2to12 zero bytes + 20-byte address
3contentsKeccak-256 of UTF-8 bytes

This encoding does not use RLP. In Solidity, the four fixed-size values can be assembled with abi.encode.

Nested values

A nested struct contributes its own hashStruct. A dynamic bytes field contributes a hash of its bytes. An array contributes a hash of its encoded elements concatenated in order; each element follows the same field-encoding rules. An empty array therefore contributes the hash of empty bytes.

Referenced struct definitions are appended to the primary type definition, sorted by type name. For a Mail containing Person values, the canonical type might be:

OrderType definition
PrimaryMail(Person from,Person to,string contents)
ReferencedPerson(string name,address wallet)

Concatenate these definitions with no separator before hashing.

This is why hashing a JSON string is not equivalent. JSON is the transport used by many wallet APIs; the digest is defined by the typed encoding, not by JSON whitespace or object-key order.

Bind the domain

Two contracts can use exactly the same Mail schema. Their signatures should remain distinguishable.

The domain supplies that context. Common fields are name, version, chainId, and verifyingContract. The optional salt field has type bytes32. A protocol includes the fields it needs, then hashes the resulting EIP712Domain struct using the same rules.

Its hashStruct is the domain separator.

This domain is a protocol namespace. It is not a browser origin and does not automatically prove that the requesting website is trustworthy.

The final preimage contains exactly 66 bytes:

Domain × message → signing digest

Domain

name
Mail demo
version
1
verifyingContract
0x3333333333333333333333333333333333333333
Encode → Keccak-256

Domain separator

0x28b8b77aaff43385d8e103ef72364b9b1e554a59b0cc0835240ca6c10b8b8ae5

Message · Mail

from
0x1111111111111111111111111111111111111111
to
0x2222222222222222222222222222222222222222
Encode → Keccak-256

Struct hash

0x2b366fdfa4274f0c8b13e95bf9422eb93a5a4adfdfe9e8b5cf56c692d82d8eda
Join with 0x1901

Signing bytes

66 B
19 01Domain · 32 BMessage · 32 B
0x190128b8b77aaff43385d8e103ef72364b9b1e554a59b0cc0835240ca6c10b8b8ae52b366fdfa4274f0c8b13e95bf9422eb93a5a4adfdfe9e8b5cf56c692d82d8eda
Keccak-256

Signing digest

32 B0x730da57892d9df094fd910d2fbf03d41ba29567ec6e9938aeb900c7d3380acf8

Inside the two encodings

Domain · 5 words

typeHash8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f
name hash8e4eb879ad15be1707858c678ce19f8331691fbb0f53857c78d7b9afc12a4ce8
version hashc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6
chainId0000000000000000000000000000000000000000000000000000000000000001
verifyingContract0000000000000000000000003333333333333333333333333333333333333333

Mail · 4 words

typeHash536e54c54e6699204b424f41f6dea846ee38ac369afec3e7c141d2c92c65e67f
from0000000000000000000000001111111111111111111111111111111111111111
to0000000000000000000000002222222222222222222222222222222222222222
contents hashb5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8

Hash that preimage once to obtain the signing digest. 0x19 and version 0x01 connect the format to EIP-191 ⁠.

The lab shows every 32-byte encoded word and the complete 66-byte preimage. Purple tracks the domain; cyan tracks the message. Changing chainId changes the domain hash while leaving the message hash intact. Changing the contents does the reverse. Either change produces a different final digest.

Sign the structure

This ethers 6.17.0 example uses the lab's schema and domain. All addresses are illustrative. The random wallet supplies from, so this executable example also checks the claimed sender.

JS
import assert from 'node:assert/strict';
import { Wallet, TypedDataEncoder, verifyTypedData } from 'ethers';
 
const wallet = Wallet.createRandom();
const domain = {
  name: 'Mail demo',
  version: '1',
  chainId: 1,
  verifyingContract: '0x3333333333333333333333333333333333333333',
};
const types = {
  Mail: [
    { name: 'from', type: 'address' },
    { name: 'to', type: 'address' },
    { name: 'contents', type: 'string' },
  ],
};
const message = {
  from: wallet.address,
  to: '0x2222222222222222222222222222222222222222',
  contents: 'Hello, Bob!',
};
 
const signature = await wallet.signTypedData(domain, types, message);
assert.equal(
  verifyTypedData(domain, types, message, signature),
  message.from,
);
assert.notEqual(
  verifyTypedData({ ...domain, chainId: 10 }, types, message, signature),
  message.from,
);
console.log(TypedDataEncoder.hash(domain, types, message));

With ethers, pass the application types to signTypedData; it builds EIP712Domain from domain. A raw eth_signTypedData_v4 request also needs primaryType and the domain type in its JSON payload. TypedDataEncoder.getPayload(domain, types, message) produces that representation.

Do not pass the typed-data digest to signMessage. That adds the personal-message envelope and produces a different signature.

Verification has state

The verifier must derive the expected domain from its own context. Accepting an arbitrary domain supplied with a signature would let the requester choose the boundary being checked.

Domain separation keeps distinct contexts apart. It does not stop the same message from being used twice in the same context. A spending protocol usually adds a nonce or order identifier, an expiry, and state that records consumption or cancellation.

Nor does a typed message guarantee a safe action. A wallet can display spender and value accurately while the requested spender remains malicious. Field visibility makes review possible; application semantics decide what the signature can do.

EIP-2612 ⁠ makes this concrete: an EIP-712 message changes token allowance only after the contract checks the signer, nonce, and deadline.

References