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 to Ethers.TypedData.Field structs). 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 - an Ethers.TypedData.Domain scoping 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.