EIP-712 Typed Data
July 17, 2026 · View on GitHub
EIP-712 is the standard for hashing and signing typed structured data rather than opaque byte strings. Instead of asking a user to sign an unreadable hash, a wallet can show the actual fields being signed (a mail message, an order, a permit, ...) and produce a signature that a smart contract can verify on-chain.
Ethers models an EIP-712 payload with Ethers.TypedData and lets you hash it, sign it (with the
Ethers.Signer.Local or Ethers.Signer.JsonRPC signers) and recover/verify the signer.
The pieces
An EIP-712 payload is made of four parts:
types- a map of struct type name to its ordered members. Each member is a%{name: ..., type: ...}pair (normalized toEthers.TypedData.Fieldstructs). Reference other structs by name ("Person"), and use array suffixes ("Person[]") just like Solidity.primary_type- the name of the top-level struct that is being signed.domain- anEthers.TypedData.Domainscoping the signature to an app/contract/chain so a signature cannot be replayed elsewhere. All five fields (name,version,chain_id,verifying_contract,salt) are optional; only the present ones participate.message- the actual values, as a map keyed by member name.
You do not declare the synthetic "EIP712Domain" type yourself - Ethers derives it from the
domain you pass.
Building an Ethers.TypedData
This guide uses the canonical Mail/Person example from the EIP-712 specification so every
intermediate value can be cross-checked against the spec.
typed_data =
Ethers.TypedData.new!(
types: %{
"Person" => [
%{name: "name", type: "string"},
%{name: "wallet", type: "address"}
],
"Mail" => [
%{name: "from", type: "Person"},
%{name: "to", type: "Person"},
%{name: "contents", type: "string"}
]
},
primary_type: "Mail",
domain: [
name: "Ether Mail",
version: "1",
chain_id: 1,
verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
],
message: %{
"from" => %{"name" => "Cow", "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},
"to" => %{"name" => "Bob", "wallet" => "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},
"contents" => "Hello, Bob!"
}
)
new/1 (and the raising new!/1) normalizes the input - field maps become
Ethers.TypedData.Field structs, message keys become strings, and the domain becomes an
Ethers.TypedData.Domain - and validates that primary_type and every referenced struct type is
defined.
Defining typed data as Elixir structs
Instead of hand-writing the types and message maps you can declare each EIP-712 struct type as
an Elixir module with use Ethers.TypedData.Schema, then build the payload from struct instances.
The typed_schema/field macros generate a matching defstruct, and field order is preserved as
the source of truth for the order-sensitive encodeType string.
defmodule Person do
use Ethers.TypedData.Schema
typed_schema "Person" do
field :name, :string
field :wallet, :address
end
end
defmodule Mail do
use Ethers.TypedData.Schema
typed_schema "Mail" do
field :from, Person
field :to, Person
field :contents, :string
end
end
A field's type can be another schema module (Person), an atom Solidity type (:string,
:address, :uint256), a literal type string ("bytes32"), or an array form ({:array, Person}
for "Person[]", {:array, inner, n} for a fixed-size array). Referenced schema modules are
walked recursively to build the full types map.
Build the payload with Ethers.TypedData.new!/2 (or new/2), passing the top-level struct and a
:domain:
typed_data =
Ethers.TypedData.new!(
%Mail{
from: %Person{name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},
to: %Person{name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},
contents: "Hello, Bob!"
},
domain: [
name: "Ether Mail",
version: "1",
chain_id: 1,
verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
]
)
The struct is expanded into the same types/primary_type/message values and validated through
new/1, so this produces exactly the same Ethers.TypedData - and therefore the same
encodeType, domain separator and digest - as the map-based example above. See
Ethers.TypedData.Schema for the full DSL reference.
Hashing
Ethers.TypedData exposes each step of the EIP-712 hashing algorithm:
# The `encodeType` string of a struct (primary type first, referenced types sorted alphabetically)
Ethers.TypedData.encode_type(typed_data, "Mail")
#=> "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
# The 32-byte domain separator (hashStruct of the EIP712Domain)
Ethers.TypedData.domain_separator(typed_data) |> Ethers.Utils.hex_encode()
#=> "0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"
# The final signing digest: keccak256(0x19 0x01 ‖ domainSeparator ‖ hashStruct(primaryType, message))
Ethers.TypedData.hash(typed_data) # raw 32-byte binary
Ethers.TypedData.hash(typed_data, :hex)
#=> "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"
Both the encodeType string and the digest above match the reference values published in the
EIP-712 specification.
Signing
With the Local signer
Ethers.Signer.Local signs the digest locally with a private key. It requires the optional
ex_secp256k1 dependency (see the README installation notes).
{:ok, signature} =
Ethers.sign_typed_data(typed_data,
signer: Ethers.Signer.Local,
signer_opts: [
private_key: "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d",
from: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"
]
)
signature
#=> "0x..." (a 0x-prefixed 65-byte signature: r ‖ s ‖ v)
The from address is validated against the private key; a mismatch returns {:error, :wrong_key}.
With the JsonRPC signer
Ethers.Signer.JsonRPC delegates to the node/wallet via the eth_signTypedData_v4 RPC method
(supported by anvil, geth, web3signer, ...). The account
must be managed by that endpoint - no private key is passed:
{:ok, signature} =
Ethers.sign_typed_data(typed_data,
signer: Ethers.Signer.JsonRPC,
signer_opts: [from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]
)
Under the hood the payload is serialized to the canonical eth_signTypedData_v4 JSON shape via
Ethers.TypedData.to_eip712_json/1 (integers as decimal strings, addresses/bytes as 0x hex).
Recovering and verifying
Given a signature you can recover the signing address or check it against an expected one. These
helpers live on Ethers.TypedData:
Ethers.TypedData.recover_signer(typed_data, signature)
#=> "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"
Ethers.TypedData.valid_signature?(
typed_data,
signature,
"0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"
)
#=> true
recover_signer/2 and valid_signature?/3 accept the signature as either a 0x-prefixed hex
string or a raw 65-byte binary. The address comparison in valid_signature?/3 is done on the
decoded 20-byte addresses, so checksum/case differences are ignored.