Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

### Enhancements

- Add [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data support: construct typed
structured data with `Ethers.TypedData`, hash it (`encode_type`, `type_hash`, `hash_struct`,
`domain_separator`, `hash`), sign it via `Ethers.sign_typed_data/2` with the `Ethers.Signer.Local`
and `Ethers.Signer.JsonRPC` (`eth_signTypedData_v4`) signers, and recover/verify the signer with
`Ethers.TypedData.recover_signer/2` and `Ethers.TypedData.valid_signature?/3`
- Add a struct DSL for EIP-712 typed data: declare typed-data struct types as Elixir modules with
`use Ethers.TypedData.Schema` and the `typed_schema`/`field` macros, then build an
`Ethers.TypedData` directly from struct instances via `Ethers.TypedData.new/2` (and `new!/2`)

## 0.6.13 (2026-07-15)

### Enhancements
Expand Down
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ It leverages Elixir's metaprogramming capabilities to provide a seamless develop
- **Built-in Contracts**: Ready-to-use interfaces for [ERC20](https://hexdocs.pm/ethers/Ethers.Contracts.ERC20.html), [ERC721](https://hexdocs.pm/ethers/Ethers.Contracts.ERC721.html), [ERC1155](https://hexdocs.pm/ethers/Ethers.Contracts.ERC1155.html), and more
- **Multi-chain Support**: Works with any EVM-compatible blockchain
- **Flexible Signing**: Extensible signer support with [built-in ones](https://hexdocs.pm/ethers/readme.html#signing-transactions)
- **EIP-712 Typed Data**: Construct, hash, sign and verify [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data
- **Event Handling**: Easy filtering and retrieval of blockchain events
- **Multicall Support**: Ability to easily perform multiple `eth_call`s using [Multicall 2/3](https://hexdocs.pm/ethers/Ethers.Multicall.html)
- **Type Safety**: Native Elixir types for all contract interactions
Expand Down Expand Up @@ -363,6 +364,93 @@ MyERC20Token.transfer("0x[Recipient]", 1000)
)
```
## Signing EIP-712 Typed Data
Ethers can construct, hash, sign and verify [EIP-712](https://eips.ethereum.org/EIPS/eip-712)
typed structured data. Build an `Ethers.TypedData`, sign it with either the `Ethers.Signer.Local`
or `Ethers.Signer.JsonRPC` signer, and recover/verify the signer.
```elixir
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!"
}
)
# Sign with a local private key
{:ok, signature} =
Ethers.sign_typed_data(typed_data,
signer: Ethers.Signer.Local,
signer_opts: [private_key: "0x...", from: "0x[Signer]"]
)
# Recover / verify the signer
Ethers.TypedData.recover_signer(typed_data, signature)
#=> "0x[Signer]"
Ethers.TypedData.valid_signature?(typed_data, signature, "0x[Signer]")
#=> true
```
You can also declare the struct types as Elixir modules and build the payload from struct
instances - the result is identical to the map-based form above:
```elixir
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
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"
]
)
```
See the [EIP-712 Typed Data guide](guides/eip-712.md) for a full walkthrough.
## Switching the ex_keccak library
`ex_keccak` is a Rustler NIF that brings keccak256 hashing to elixir.
Expand Down
205 changes: 205 additions & 0 deletions guides/eip-712.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# EIP-712 Typed Data

[EIP-712](https://eips.ethereum.org/EIPS/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.

```elixir
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.

```elixir
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`:

```elixir
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:

```elixir
# 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).

```elixir
{: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](https://github.com/Consensys/web3signer), ...). The account
must be managed by that endpoint - no private key is passed:

```elixir
{: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`:

```elixir
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.
Loading
Loading