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
Domain5 × 32-byte words
typeHash · EIP712Domain8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f
keccak256("Mail demo")8e4eb879ad15be1707858c678ce19f8331691fbb0f53857c78d7b9afc12a4ce8
keccak256("1")c89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6
chainId · 10000000000000000000000000000000000000000000000000000000000000001
verifyingContract · 0x3333…33330000000000000000000000003333333333333333333333333333333333333333
Keccak-256
0x28b8b77aaff43385d8e103ef72364b9b1e554a59b0cc0835240ca6c10b8b8ae5
Message · Mail4 × 32-byte words
typeHash · Mail536e54c54e6699204b424f41f6dea846ee38ac369afec3e7c141d2c92c65e67f
from · 0x1111…11110000000000000000000000001111111111111111111111111111111111111111
to · 0x2222…22220000000000000000000000002222222222222222222222222222222222222222
keccak256(contents)b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8
Keccak-256
0x2b366fdfa4274f0c8b13e95bf9422eb93a5a4adfdfe9e8b5cf56c692d82d8eda
19 01 · 2 BdomainSeparator · 32 BhashStruct · 32 B
Signing preimage · 66 bytes0x190128b8b77aaff43385d8e103ef72364b9b1e554a59b0cc0835240ca6c10b8b8ae52b366fdfa4274f0c8b13e95bf9422eb93a5a4adfdfe9e8b5cf56c692d82d8eda
Keccak-256
0x730da57892d9df094fd910d2fbf03d41ba29567ec6e9938aeb900c7d3380acf8

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