• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ExWeb3 / elixir_ethers / 968481c35b75f0dbe60db057ff1e827abec3de29

17 Jul 2026 09:40PM UTC coverage: 88.248% (+1.1%) from 87.197%
968481c35b75f0dbe60db057ff1e827abec3de29

push

github

web-flow
Add EIP-712 Typed Data Support (#264)

* Bare minimum EIP-712 definition

* Add EIP712 Schema

* Address PR review: harden typed-data signing/recovery contracts

- Ethers.sign_typed_data/2: return {:error, :not_supported} instead of
  crashing with UndefinedFunctionError when the resolved signer does not
  implement the optional sign_typed_data/2 callback (scoped rescue; other
  UndefinedFunctionErrors are re-raised).
- Signer.JsonRPC.sign_typed_data/2: return {:error, :missing_from_address}
  instead of raising KeyError when :from is absent, honoring the callback's
  {:ok, _} | {:error, _} contract.
- TypedData.recover_signer/2: return {:error, :invalid_signature} on
  malformed input (bad length/hex) instead of raising, matching the
  documented return and making valid_signature?/3 robust; also accept v as
  raw parity (0/1) in addition to 27/28 for wider signer interoperability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

226 of 243 new or added lines in 8 files covered. (93.0%)

1269 of 1438 relevant lines covered (88.25%)

41.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.18
/lib/ethers/typed_data/encoder.ex
1
defmodule Ethers.TypedData.Encoder do
2
  @moduledoc false
3

4
  # The pure EIP-712 (https://eips.ethereum.org/EIPS/eip-712) encoding / hashing engine.
5
  #
6
  # This is the internal implementation of the EIP-712 algorithm (encodeType, typeHash,
7
  # encodeData, hashStruct, the domain separator and the final signing digest). The public
8
  # documented surface lives on `Ethers.TypedData`; this module holds the logic so it can be unit
9
  # tested in isolation.
10
  #
11
  # All hashing uses keccak-256 (`Ethers.keccak_module/0`) and all atomic member words are produced
12
  # with the same ABI.TypeEncoder machinery used elsewhere in the library, so encoding is
13
  # byte-for-byte consistent with the ABI layer.
14

15
  alias Ethers.TypedData
16
  alias Ethers.TypedData.Domain
17
  alias Ethers.TypedData.Field
18
  alias Ethers.Utils
19

20
  @typedoc "A 32-byte keccak-256 hash (binary)."
21
  @type hash :: binary()
22

23
  @doc """
24
  Returns the EIP-712 `encodeType` string for `type_name`.
25

26
  The primary type is rendered first, followed by every transitively referenced struct type
27
  sorted alphabetically by name. Each type renders as `Name(type1 field1,type2 field2,...)`.
28

29
  ## Example
30

31
      iex> td = Ethers.TypedData.new!(
32
      ...>   types: %{
33
      ...>     "Person" => [%{name: "name", type: "string"}, %{name: "wallet", type: "address"}],
34
      ...>     "Mail" => [
35
      ...>       %{name: "from", type: "Person"},
36
      ...>       %{name: "to", type: "Person"},
37
      ...>       %{name: "contents", type: "string"}
38
      ...>     ]
39
      ...>   },
40
      ...>   primary_type: "Mail",
41
      ...>   domain: [name: "Ether Mail"],
42
      ...>   message: %{}
43
      ...> )
44
      iex> Ethers.TypedData.Encoder.encode_type(td, "Mail")
45
      "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
46
  """
47
  @spec encode_type(TypedData.t(), String.t()) :: String.t()
48
  def encode_type(%TypedData{types: types}, type_name) do
49
    deps = collect_dependencies(types, type_name, MapSet.new())
150✔
50

51
    sorted_deps =
150✔
52
      deps
53
      |> MapSet.delete(type_name)
54
      |> MapSet.to_list()
55
      |> Enum.sort()
56

57
    Enum.map_join([type_name | sorted_deps], &render_type(types, &1))
150✔
58
  end
59

60
  @doc """
61
  Returns `keccak256(encode_type(typed_data, type_name))` as a 32-byte binary.
62
  """
63
  @spec type_hash(TypedData.t(), String.t()) :: hash()
64
  def type_hash(%TypedData{} = typed_data, type_name) do
65
    typed_data
66
    |> encode_type(type_name)
67
    |> keccak()
140✔
68
  end
69

70
  @doc """
71
  Returns `typeHash ‖ word₁ ‖ word₂ ‖ …`, one 32-byte word per member of `type_name`.
72

73
  `value` must be a string-keyed map holding a value for each member of `type_name`.
74
  """
75
  @spec encode_data(TypedData.t(), String.t(), map()) :: binary()
76
  def encode_data(%TypedData{types: types} = typed_data, type_name, value) do
77
    fields = Map.fetch!(types, type_name)
139✔
78

79
    words =
139✔
80
      Enum.map_join(fields, fn %Field{name: name, type: type} ->
81
        encode_field(type, Map.fetch!(value, name), typed_data)
385✔
82
      end)
83

84
    type_hash(typed_data, type_name) <> words
139✔
85
  end
86

87
  @doc """
88
  Returns `keccak256(encode_data(typed_data, type_name, value))` as a 32-byte binary.
89
  """
90
  @spec hash_struct(TypedData.t(), String.t(), map()) :: hash()
91
  def hash_struct(%TypedData{} = typed_data, type_name, value) do
92
    typed_data
93
    |> encode_data(type_name, value)
94
    |> keccak()
139✔
95
  end
96

97
  @doc """
98
  Returns the EIP-712 domain separator (`hashStruct("EIP712Domain", domain)`) as a 32-byte binary.
99

100
  Only the present (non-nil) domain fields participate, in canonical EIP-712 order.
101
  """
102
  @spec domain_separator(TypedData.t()) :: hash()
103
  def domain_separator(%TypedData{domain: %Domain{} = domain}) do
104
    fields = Domain.present_fields(domain)
38✔
105

106
    value =
38✔
107
      Map.new(fields, fn %Field{name: name} -> {name, domain_field_value(domain, name)} end)
148✔
108

109
    domain_typed_data = %TypedData{
38✔
110
      domain: domain,
111
      types: %{"EIP712Domain" => fields},
112
      primary_type: "EIP712Domain",
113
      message: value
114
    }
115

116
    hash_struct(domain_typed_data, "EIP712Domain", value)
38✔
117
  end
118

119
  @doc """
120
  Returns the EIP-712 signing digest of `typed_data`.
121

122
  This is `keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(primaryType, message))`.
123

124
  ## Parameters
125

126
  - `typed_data` - the `Ethers.TypedData` to hash.
127
  - `format` - either `:bin` (default, returns the raw 32-byte binary) or `:hex` (returns a
128
    `0x`-prefixed hex string).
129
  """
130
  @spec hash(TypedData.t()) :: hash()
131
  @spec hash(TypedData.t(), :bin | :hex) :: binary() | String.t()
132
  def hash(typed_data, format \\ :bin)
24✔
133

134
  def hash(%TypedData{} = typed_data, :bin) do
135
    domain_separator = domain_separator(typed_data)
32✔
136
    struct_hash = hash_struct(typed_data, typed_data.primary_type, typed_data.message)
32✔
137

138
    keccak(<<0x19, 0x01>> <> domain_separator <> struct_hash)
32✔
139
  end
140

141
  def hash(%TypedData{} = typed_data, :hex) do
142
    typed_data
143
    |> hash(:bin)
144
    |> Utils.hex_encode()
7✔
145
  end
146

147
  # --- internal helpers -----------------------------------------------------
148

149
  # Transitively collects the set of struct type names referenced from `type_name` (inclusive).
150
  @spec collect_dependencies(map(), String.t(), MapSet.t()) :: MapSet.t()
151
  defp collect_dependencies(types, type_name, acc) do
152
    if MapSet.member?(acc, type_name) do
228✔
153
      acc
38✔
154
    else
155
      acc = MapSet.put(acc, type_name)
190✔
156

157
      types
158
      |> Map.fetch!(type_name)
159
      |> Enum.reduce(acc, &collect_field_dependencies(types, &1, &2))
190✔
160
    end
161
  end
162

163
  @spec collect_field_dependencies(map(), Field.t(), MapSet.t()) :: MapSet.t()
164
  defp collect_field_dependencies(types, %Field{type: type}, acc) do
165
    base = base_type(type)
499✔
166

167
    if Map.has_key?(types, base) do
499✔
168
      collect_dependencies(types, base, acc)
78✔
169
    else
170
      acc
421✔
171
    end
172
  end
173

174
  @spec render_type(map(), String.t()) :: String.t()
175
  defp render_type(types, type_name) do
176
    members =
190✔
177
      types
178
      |> Map.fetch!(type_name)
179
      |> Enum.map_join(",", fn %Field{name: name, type: type} -> "#{type} #{name}" end)
499✔
180

181
    "#{type_name}(#{members})"
190✔
182
  end
183

184
  # Encodes a single member to its 32-byte word (or the intermediate hash for arrays/references).
185
  @spec encode_field(String.t(), term(), TypedData.t()) :: binary()
186
  defp encode_field(type, value, %TypedData{types: types} = typed_data) do
187
    cond do
399✔
188
      array_type?(type) ->
189
        element_type = element_type(type)
8✔
190

191
        value
192
        |> Enum.map_join(fn element -> encode_field(element_type, element, typed_data) end)
14✔
193
        |> keccak()
8✔
194

195
      Map.has_key?(types, base_type(type)) ->
391✔
196
        hash_struct(typed_data, base_type(type), value)
62✔
197

198
      true ->
329✔
199
        encode_atomic(type, value)
329✔
200
    end
201
  end
202

203
  @spec encode_atomic(String.t(), term()) :: binary()
204
  defp encode_atomic(type, value) do
205
    case ABI.FunctionSelector.decode_type(type) do
329✔
206
      :string ->
207
        keccak(value)
171✔
208

209
      :bytes ->
210
        keccak(normalize_binary(value))
2✔
211

212
      {:bytes, _size} = type_tuple ->
213
        encode_word(normalize_binary(value), type_tuple)
6✔
214

215
      type_tuple ->
216
        encode_word(Utils.prepare_arg(value, type_tuple), type_tuple)
150✔
217
    end
218
  end
219

220
  @spec encode_word(term(), ABI.FunctionSelector.type()) :: binary()
221
  defp encode_word(prepared, type_tuple) do
222
    ABI.TypeEncoder.encode([prepared], [type_tuple])
156✔
223
  end
224

225
  # Normalizes a `bytes`/`bytesN` value: `0x`-hex strings are decoded, raw binaries pass through.
226
  @spec normalize_binary(binary()) :: binary()
227
  defp normalize_binary(<<"0x", _rest::binary>> = hex), do: Utils.hex_decode!(hex)
7✔
228
  defp normalize_binary(binary) when is_binary(binary), do: binary
1✔
229

230
  @spec domain_field_value(Domain.t(), String.t()) :: binary() | non_neg_integer() | nil
231
  defp domain_field_value(%Domain{} = domain, "name"), do: domain.name
38✔
232
  defp domain_field_value(%Domain{} = domain, "version"), do: domain.version
36✔
233
  defp domain_field_value(%Domain{} = domain, "chainId"), do: domain.chain_id
38✔
234
  defp domain_field_value(%Domain{} = domain, "verifyingContract"), do: domain.verifying_contract
36✔
NEW
235
  defp domain_field_value(%Domain{} = domain, "salt"), do: domain.salt
×
236

237
  # True when `type` has an array suffix (`T[]` or `T[n]`).
238
  @spec array_type?(String.t()) :: boolean()
239
  defp array_type?(type), do: Regex.match?(~r/\[\d*\]$/, type)
399✔
240

241
  # Removes ONE array-suffix level (`T[][]` -> `T[]`, `T[n]` -> `T`).
242
  @spec element_type(String.t()) :: String.t()
243
  defp element_type(type), do: Regex.replace(~r/\[\d*\]$/, type, "")
8✔
244

245
  # Strips ALL array suffixes returning the base struct/atomic name.
246
  @spec base_type(String.t()) :: String.t()
247
  defp base_type(type), do: type |> String.split("[", parts: 2) |> hd()
952✔
248

249
  @spec keccak(binary()) :: hash()
250
  defp keccak(binary), do: Ethers.keccak_module().hash_256(binary)
492✔
251
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc