diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab05a2b..5b46e748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 606c0c13..0853f1a1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/guides/eip-712.md b/guides/eip-712.md new file mode 100644 index 00000000..b5894c7b --- /dev/null +++ b/guides/eip-712.md @@ -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. diff --git a/lib/ethers.ex b/lib/ethers.ex index d063a618..eb882e98 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -467,6 +467,73 @@ defmodule Ethers do end end + @doc """ + Signs an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed-data payload and returns the + signature as a `0x`-prefixed hex string (65 bytes: `r ‖ s ‖ v`). + + Unlike `sign_transaction/2`, no transaction fields are auto-fetched; the given + `Ethers.TypedData` is signed as-is. The signer is resolved the same way as for transactions: + the `:signer` option, otherwise the `:default_signer` application env, otherwise + `Ethers.Signer.JsonRPC`. + + ## Options + + - `:signer`: The signer module to use. (e.g. `Ethers.Signer.Local` or `Ethers.Signer.JsonRPC`) + - `:signer_opts`: Options passed to the signer. Use `:from` here (and `:private_key` for + `Ethers.Signer.Local`) so the signer knows which account signs. + + ## Examples + + ```elixir + typed_data = Ethers.TypedData.new!(...) + + Ethers.sign_typed_data(typed_data, + signer: Ethers.Signer.Local, + signer_opts: [private_key: "0x...", from: "0x..."] + ) + #=> {:ok, "0x..."} + ``` + """ + @spec sign_typed_data(Ethers.TypedData.t(), Keyword.t()) :: + {:ok, binary()} | {:error, term()} + def sign_typed_data(typed_data, opts \\ []) do + {opts, _} = Keyword.split(opts, @option_keys) + + default_signer = default_signer() || Ethers.Signer.JsonRPC + + with {:ok, signer} <- get_signer(opts, default_signer) do + do_sign_typed_data(signer, typed_data, build_signer_opts(%{}, opts)) + end + end + + # `sign_typed_data/2` is an optional signer callback. If the resolved signer does not + # implement it, translate the resulting UndefinedFunctionError into `{:error, :not_supported}` + # (matching the behaviour contract in `Ethers.Signer`). Any other UndefinedFunctionError raised + # from within the signer is re-raised untouched. + defp do_sign_typed_data(signer, typed_data, signer_opts) do + signer.sign_typed_data(typed_data, signer_opts) + rescue + error in UndefinedFunctionError -> + case error do + %UndefinedFunctionError{module: ^signer, function: :sign_typed_data, arity: 2} -> + {:error, :not_supported} + + _ -> + reraise error, __STACKTRACE__ + end + end + + @doc """ + Same as `Ethers.sign_typed_data/2` but raises on error. + """ + @spec sign_typed_data!(Ethers.TypedData.t(), Keyword.t()) :: binary() | no_return() + def sign_typed_data!(typed_data, opts \\ []) do + case sign_typed_data(typed_data, opts) do + {:ok, signature} -> signature + {:error, reason} -> raise ExecutionError, reason + end + end + @doc """ Makes an eth_estimate_gas rpc call with the given parameters and overrides. diff --git a/lib/ethers/signer.ex b/lib/ethers/signer.ex index 761562fd..c19af2cf 100644 --- a/lib/ethers/signer.ex +++ b/lib/ethers/signer.ex @@ -19,6 +19,10 @@ defmodule Ethers.Signer do For signing transactions in custom signers the functions in `Ethers.Transaction` module might become handy. Check out the source code of built in signers for in depth info. + A signer may also implement the optional `c:sign_typed_data/2` callback to support signing + [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data (see `Ethers.TypedData`). + Signers that do not implement it will simply not support typed-data signing. + ## Globally Default Signer If desired, a signer can be configured to be used for all operations in Ethers using elixir config. @@ -56,4 +60,22 @@ defmodule Ethers.Signer do """ @callback accounts(opts :: Keyword.t()) :: {:ok, [Types.t_address()]} | {:error, reason :: :not_supported | term()} + + @doc """ + Signs an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed-data payload and returns the + signature. + + This is an optional callback. Signers that do not implement it do not support typed-data + signing. + + ## Parameters + - typed_data: The typed-data payload to sign. (An `Ethers.TypedData` struct) + - opts: Other options passed to the signer as `signer_opts`. + + Returns `{:ok, signature}` where `signature` is a `0x`-prefixed 65-byte signature hex string. + """ + @callback sign_typed_data(typed_data :: Ethers.TypedData.t(), opts :: Keyword.t()) :: + {:ok, binary()} | {:error, reason :: term()} + + @optional_callbacks sign_typed_data: 2 end diff --git a/lib/ethers/signer/json_rpc.ex b/lib/ethers/signer/json_rpc.ex index 97aa7143..e1a99cad 100644 --- a/lib/ethers/signer/json_rpc.ex +++ b/lib/ethers/signer/json_rpc.ex @@ -1,7 +1,7 @@ defmodule Ethers.Signer.JsonRPC do @moduledoc """ Signer capable of signing transactions with a JSON RPC server - capable of `eth_signTransaction` and `eth_accounts` RPC functions. + capable of `eth_signTransaction`, `eth_signTypedData_v4` and `eth_accounts` RPC functions. ## Signer Options @@ -32,6 +32,24 @@ defmodule Ethers.Signer.JsonRPC do rpc_module.request("eth_signTransaction", [tx_map], opts) end + @impl true + def sign_typed_data(typed_data, opts) do + case Keyword.get(opts, :from) do + nil -> + {:error, :missing_from_address} + + from -> + {rpc_module, opts} = Keyword.pop(opts, :rpc_module, Ethereumex.HttpClient) + + json = + typed_data + |> Ethers.TypedData.to_eip712_json() + |> Jason.encode!() + + rpc_module.request("eth_signTypedData_v4", [from, json], opts) + end + end + @impl true def accounts(opts) do {rpc_module, opts} = Keyword.pop(opts, :rpc_module, Ethereumex.HttpClient) diff --git a/lib/ethers/signer/local.ex b/lib/ethers/signer/local.ex index 2f556b70..d81eac11 100644 --- a/lib/ethers/signer/local.ex +++ b/lib/ethers/signer/local.ex @@ -43,6 +43,17 @@ defmodule Ethers.Signer.Local do end end + @impl true + def sign_typed_data(typed_data, opts) do + digest = Ethers.TypedData.hash(typed_data) + + with {:ok, private_key} <- private_key(opts), + :ok <- validate_private_key(private_key, Keyword.get(opts, :from)), + {:ok, {r, s, recovery_id}} <- secp256k1_module().sign(digest, private_key) do + {:ok, Utils.hex_encode(r <> s <> <>)} + end + end + @impl true def accounts(opts) do with {:ok, private_key} <- private_key(opts), @@ -54,6 +65,9 @@ defmodule Ethers.Signer.Local do @impl true def sign_transaction(_tx, _opts), do: {:error, :secp256k1_module_not_loaded} + @impl true + def sign_typed_data(_typed_data, _opts), do: {:error, :secp256k1_module_not_loaded} + @impl true def accounts(_opts), do: {:error, :secp256k1_module_not_loaded} end diff --git a/lib/ethers/typed_data.ex b/lib/ethers/typed_data.ex new file mode 100644 index 00000000..ecda32f3 --- /dev/null +++ b/lib/ethers/typed_data.ex @@ -0,0 +1,516 @@ +defmodule Ethers.TypedData do + @moduledoc """ + Models an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data payload. + + An `Ethers.TypedData` mirrors the canonical `eth_signTypedData_v4` JSON shape so the same + struct serves both hashing (the local signing pipeline) and JSON-RPC signing. It is built from: + + - `types` - a map of `type_name` (`String.t()`) to an ordered list of + `Ethers.TypedData.Field` structs describing that struct's members. The synthetic + `"EIP712Domain"` entry must **not** be included here - it is derived from the `domain`. + - `primary_type` - the name (`String.t()`) of the top-level struct being signed. Must be a key + of `types`. + - `message` - a map of member name to value. Keys are normalized to strings internally. + - `domain` - an `Ethers.TypedData.Domain` struct scoping the signature. + + ## Example (the canonical Mail/Person example) + + The following builds the `Mail` message from the EIP-712 specification and reproduces the + reference `encodeType` string, domain separator and signing digest published by the spec: + + iex> 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!" + ...> } + ...> ) + iex> Ethers.TypedData.encode_type(typed_data, "Mail") + "Mail(Person from,Person to,string contents)Person(string name,address wallet)" + iex> Ethers.TypedData.hash(typed_data, :hex) + "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + iex> Ethers.Utils.hex_encode(Ethers.TypedData.domain_separator(typed_data)) + "0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" + """ + + alias Ethers.TypedData.Domain + alias Ethers.TypedData.Encoder + alias Ethers.TypedData.Field + alias Ethers.TypedData.Schema + alias Ethers.Utils + + @enforce_keys [:domain, :types, :primary_type, :message] + defstruct [:domain, :types, :primary_type, :message] + + @typedoc """ + An EIP-712 typed-data payload. + + - `domain` - the `Ethers.TypedData.Domain` scoping the signature. + - `types` - map of struct type name to ordered list of `Ethers.TypedData.Field` structs. + - `primary_type` - the top-level struct type name being signed. + - `message` - the message data as a map with string keys. + """ + @type t :: %__MODULE__{ + domain: Domain.t(), + types: %{String.t() => [Field.t()]}, + primary_type: String.t(), + message: %{String.t() => term()} + } + + # Matches any Solidity atomic (non-reference) base type: address, bool, string, bytes, + # bytes1..bytes32, uintN and intN (any bit width). + @atomic_type_regex ~r/^(address|bool|string|bytes([1-9]|[12][0-9]|3[0-2])?|u?int([0-9]+)?)$/ + + @doc """ + Builds an `Ethers.TypedData` from the given parameters, normalizing and validating them. + + ## Parameters (keyword list or map) + + - `:types` - a map of type name to a list of field definitions. Each field may be a + `%Ethers.TypedData.Field{}`, a map with atom keys (`%{name: ..., type: ...}`), or a map with + string keys (`%{"name" => ..., "type" => ...}`). All are normalized to `Field` structs. + - `:primary_type` - the top-level struct type name. Must exist in `:types`. + - `:domain` - an `Ethers.TypedData.Domain` struct, or a keyword/map accepted by + `Ethers.TypedData.Domain.new/1`. + - `:message` - a map of member name to value. Both atom and string keys are accepted and + normalized to string keys. + + ## Validation + + Returns `{:error, reason}` when: + + - `:primary_type` is not a key of `:types` (`{:error, {:unknown_primary_type, name}}`). + - a member references a struct type that is not defined in `:types` + (`{:error, {:undefined_type, name}}`). + + Otherwise returns `{:ok, %Ethers.TypedData{}}`. + """ + @spec new(struct()) :: {:ok, t()} | {:error, term()} + @spec new(keyword() | map()) :: {:ok, t()} | {:error, term()} + def new(struct) when is_struct(struct), do: new(struct, []) + + def new(params) do + params = Map.new(params) + + with {:ok, types} <- fetch(params, :types), + {:ok, primary_type} <- fetch(params, :primary_type), + {:ok, message} <- fetch(params, :message) do + types = normalize_types(types) + primary_type = to_string(primary_type) + domain = Domain.new(Map.get(params, :domain, %{})) + + with :ok <- validate_primary_type(types, primary_type), + :ok <- validate_references(types) do + {:ok, + %__MODULE__{ + domain: domain, + types: types, + primary_type: primary_type, + message: normalize_message(message) + }} + end + end + end + + @doc """ + Builds an `Ethers.TypedData` from a schema struct instance (see `Ethers.TypedData.Schema`). + + Declare the EIP-712 struct types as modules with `use Ethers.TypedData.Schema`, then pass an + instance of the top-level struct. The struct is expanded into `types`/`primary_type`/`message` + (walking referenced schema modules transitively) and validated via `new/1`, so the result is + identical to the equivalent map-based `new/1` call. + + `opts` may carry `:domain` (a keyword/map accepted by `Ethers.TypedData.Domain.new/1`). + + ## Example + + # given `Person`/`Mail` schema modules (see `Ethers.TypedData.Schema`) + 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"] + ) + """ + @spec new(struct(), keyword()) :: {:ok, t()} | {:error, term()} + def new(struct, opts) when is_struct(struct) do + struct |> Schema.to_params(opts) |> new() + end + + @doc """ + Same as `new/1` but raises an `ArgumentError` on error. + """ + @spec new!(struct()) :: t() | no_return() + @spec new!(keyword() | map()) :: t() | no_return() + def new!(struct) when is_struct(struct), do: new!(struct, []) + + def new!(params) do + case new(params) do + {:ok, typed_data} -> typed_data + {:error, reason} -> raise ArgumentError, "invalid typed data: #{inspect(reason)}" + end + end + + @doc """ + Same as `new/2` but raises an `ArgumentError` on error. + """ + @spec new!(struct(), keyword()) :: t() | no_return() + def new!(struct, opts) when is_struct(struct) do + case new(struct, opts) do + {:ok, typed_data} -> typed_data + {:error, reason} -> raise ArgumentError, "invalid typed data: #{inspect(reason)}" + end + end + + @spec fetch(map(), :types | :primary_type | :message) :: + {:ok, term()} | {:error, {:missing_key, :types | :primary_type | :message}} + defp fetch(params, key) do + case Map.fetch(params, key) do + {:ok, value} -> {:ok, value} + :error -> {:error, {:missing_key, key}} + end + end + + @spec normalize_types(map()) :: %{String.t() => [Field.t()]} + defp normalize_types(types) do + Map.new(types, fn {type_name, fields} -> + {to_string(type_name), Enum.map(fields, &Field.new/1)} + end) + end + + @spec normalize_message(term()) :: term() + defp normalize_message(message) when is_map(message) and not is_struct(message) do + Map.new(message, fn {key, value} -> {to_string(key), normalize_message(value)} end) + end + + defp normalize_message(list) when is_list(list), do: Enum.map(list, &normalize_message/1) + + defp normalize_message(value), do: value + + @spec validate_primary_type(%{String.t() => [Field.t()]}, String.t()) :: + :ok | {:error, {:unknown_primary_type, String.t()}} + defp validate_primary_type(types, primary_type) do + if Map.has_key?(types, primary_type) do + :ok + else + {:error, {:unknown_primary_type, primary_type}} + end + end + + @spec validate_references(%{String.t() => [Field.t()]}) :: + :ok | {:error, {:undefined_type, String.t()}} + defp validate_references(types) do + types + |> Enum.flat_map(fn {_name, fields} -> fields end) + |> Enum.reduce_while(:ok, fn %Field{type: type}, :ok -> + base = base_type(type) + + cond do + atomic_type?(base) -> {:cont, :ok} + Map.has_key?(types, base) -> {:cont, :ok} + true -> {:halt, {:error, {:undefined_type, base}}} + end + end) + end + + @doc false + # Strips array suffixes (`[]`, `[n]`) returning the base type name. + @spec base_type(String.t()) :: String.t() + def base_type(type) do + case String.split(type, "[", parts: 2) do + [base | _] -> base + end + end + + @spec atomic_type?(String.t()) :: boolean() + defp atomic_type?(base), do: Regex.match?(@atomic_type_regex, base) + + @doc """ + Serializes an `Ethers.TypedData` into the canonical `eth_signTypedData_v4` JSON-compatible map. + + The returned map uses string keys and JSON-ready scalar values so it can be encoded with + `Jason.encode!/1` and handed to a wallet / node. Its shape is: + + %{ + "types" => %{ + "EIP712Domain" => [%{"name" => ..., "type" => ...}, ...], + => [%{"name" => ..., "type" => ...}, ...] + }, + "primaryType" => "Mail", + "domain" => %{...present domain fields...}, + "message" => %{...} + } + + ## Value serialization + + - `address` - `0x` checksummed hex (accepts a `0x`-hex string or a 20-byte binary). + - `bytesN` / `bytes` - `0x` hex (accepts a `0x`-hex string or a binary). + - `uintN` / `intN` - decimal string (accepts an integer, a decimal string, or a `0x`-hex + string). Always serialized as a decimal string for full `uint256`-range interoperability. + - `bool` / `string` - passed through unchanged. + - reference struct types - recursed into as nested maps. + - array types (`T[]` / `T[n]`, including nested arrays) - serialized as lists element by element. + + The `"EIP712Domain"` entry is derived from the domain's present (non-nil) fields in canonical + order; the caller must not include it in `types`. + """ + @spec to_eip712_json(t()) :: %{String.t() => term()} + def to_eip712_json(%__MODULE__{} = typed_data) do + %__MODULE__{ + domain: domain, + types: types, + primary_type: primary_type, + message: message + } = typed_data + + %{ + "types" => build_types_json(domain, types), + "primaryType" => primary_type, + "domain" => serialize_domain(domain), + "message" => serialize_struct(primary_type, message, types) + } + end + + @doc """ + Returns the EIP-712 `encodeType` string for `type_name` (e.g. + `"Mail(Person from,Person to,string contents)Person(string name,address wallet)"`). + """ + @spec encode_type(t(), String.t()) :: String.t() + defdelegate encode_type(typed_data, type_name), to: Encoder + + @doc """ + Returns the 32-byte `typeHash` (`keccak256(encodeType(type_name))`). + """ + @spec type_hash(t(), String.t()) :: binary() + defdelegate type_hash(typed_data, type_name), to: Encoder + + @doc """ + Returns the EIP-712 `encodeData` bytes for `value` as type `type_name` + (`typeHash ‖ enc(memberᵢ)…`). + """ + @spec encode_data(t(), String.t(), term()) :: binary() + defdelegate encode_data(typed_data, type_name, value), to: Encoder + + @doc """ + Returns the 32-byte `hashStruct(type_name, value)` = `keccak256(encodeData(type_name, value))`. + """ + @spec hash_struct(t(), String.t(), term()) :: binary() + defdelegate hash_struct(typed_data, type_name, value), to: Encoder + + @doc """ + Returns the 32-byte EIP-712 `domainSeparator` (`hashStruct("EIP712Domain", domain)`). + """ + @spec domain_separator(t()) :: binary() + defdelegate domain_separator(typed_data), to: Encoder + + @doc """ + Returns the EIP-712 signing digest + `keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(primaryType, message))`. + + With `:bin` (default) returns the raw 32-byte binary; with `:hex` returns `0x`-prefixed hex. + """ + @spec hash(t()) :: binary() + defdelegate hash(typed_data), to: Encoder + + @doc """ + Returns the EIP-712 signing digest of `typed_data` in the requested `format`. + + With `:bin` returns the raw 32-byte binary; with `:hex` returns a `0x`-prefixed hex string. + See `hash/1` for the digest definition. + """ + @spec hash(t(), :bin | :hex) :: binary() + defdelegate hash(typed_data, format), to: Encoder + + @doc """ + Recovers the signer address from an EIP-712 typed-data payload and its signature. + + The signing digest is recomputed from `typed_data` (via `hash/1`) and the signer's public key + is recovered from the signature, then converted to its checksummed `0x` address. + + `signature` may be given as a `0x`-prefixed hex string (130 hex chars) or a raw 65-byte binary + (`r ‖ s ‖ v`). + + ## Returns + + - a checksummed `0x` address string on success. + - `{:error, reason}` if the public key could not be recovered. + + ## Examples + + ```elixir + {:ok, sig} = + Ethers.sign_typed_data(typed_data, + signer: Ethers.Signer.Local, + signer_opts: [private_key: key] + ) + + Ethers.TypedData.recover_signer(typed_data, sig) + #=> "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" + ``` + """ + @spec recover_signer(t(), binary()) :: Ethers.Types.t_address() | {:error, term()} + def recover_signer(%__MODULE__{} = typed_data, signature) do + with {:ok, <>} <- + normalize_signature(signature), + {:ok, recovery_id} <- normalize_recovery_id(v), + {:ok, public_key} <- + Ethers.secp256k1_module().recover(hash(typed_data), r, s, recovery_id) do + Utils.public_key_to_address(public_key) + end + end + + @doc """ + Checks whether `signature` over `typed_data` was produced by `expected_address`. + + Recovers the signer via `recover_signer/2` and compares it to `expected_address`. The + comparison is done on the decoded 20-byte addresses, so checksum/case differences are ignored. + + `signature` may be a `0x`-prefixed hex string or a raw 65-byte binary. + + ## Examples + + ```elixir + Ethers.TypedData.valid_signature?(typed_data, sig, "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1") + #=> true + ``` + """ + @spec valid_signature?(t(), binary(), Ethers.Types.t_address()) :: boolean() + def valid_signature?(%__MODULE__{} = typed_data, signature, expected_address) do + case recover_signer(typed_data, signature) do + {:error, _reason} -> + false + + recovered -> + Utils.decode_address!(recovered) == Utils.decode_address!(expected_address) + end + end + + @spec normalize_signature(binary()) :: {:ok, <<_::520>>} | {:error, :invalid_signature} + defp normalize_signature("0x" <> _ = hex) do + case Utils.hex_decode(hex) do + {:ok, binary} -> normalize_signature(binary) + :error -> {:error, :invalid_signature} + end + end + + defp normalize_signature(<<_::binary-size(65)>> = binary), do: {:ok, binary} + defp normalize_signature(binary) when is_binary(binary), do: {:error, :invalid_signature} + + # Accepts both the message-signature convention (`v ∈ {27, 28}`) and raw parity + # (`v ∈ {0, 1}`) that some signers/providers return, mapping both to a `0..1` recovery id. + @spec normalize_recovery_id(byte()) :: {:ok, 0..1} | {:error, :invalid_signature} + defp normalize_recovery_id(v) when v in [0, 27], do: {:ok, 0} + defp normalize_recovery_id(v) when v in [1, 28], do: {:ok, 1} + defp normalize_recovery_id(_v), do: {:error, :invalid_signature} + + @spec build_types_json(Domain.t(), %{String.t() => [Field.t()]}) :: %{String.t() => [map()]} + defp build_types_json(domain, types) do + struct_entries = + Map.new(types, fn {name, fields} -> {name, Enum.map(fields, &field_json/1)} end) + + domain_entry = Enum.map(Domain.present_fields(domain), &field_json/1) + + Map.put(struct_entries, "EIP712Domain", domain_entry) + end + + @spec field_json(Field.t()) :: %{String.t() => String.t()} + defp field_json(%Field{name: name, type: type}), do: %{"name" => name, "type" => type} + + @spec serialize_domain(Domain.t()) :: %{String.t() => term()} + defp serialize_domain(%Domain{} = domain) do + values = %{ + "name" => domain.name, + "version" => domain.version, + "chainId" => domain.chain_id, + "verifyingContract" => domain.verifying_contract, + "salt" => domain.salt + } + + Map.new(Domain.present_fields(domain), fn %Field{name: name, type: type} -> + {name, serialize_atomic(type, Map.fetch!(values, name))} + end) + end + + @spec serialize_value(String.t(), term(), %{String.t() => [Field.t()]}) :: term() + defp serialize_value(type, value, types) do + cond do + array_type?(type) -> + element_type = array_element_type(type) + Enum.map(value, &serialize_value(element_type, &1, types)) + + Map.has_key?(types, type) -> + serialize_struct(type, value, types) + + true -> + serialize_atomic(type, value) + end + end + + @spec serialize_struct(String.t(), map(), %{String.t() => [Field.t()]}) :: %{ + String.t() => term() + } + defp serialize_struct(type, value, types) do + fields = Map.fetch!(types, type) + + Map.new(fields, fn %Field{name: name, type: field_type} -> + {name, serialize_value(field_type, Map.fetch!(value, name), types)} + end) + end + + @spec serialize_atomic(String.t(), term()) :: term() + defp serialize_atomic("address", value), do: value |> to_binary() |> Utils.encode_address!() + defp serialize_atomic("bool", value), do: value + defp serialize_atomic("string", value), do: value + defp serialize_atomic("bytes", value), do: value |> to_binary() |> Utils.hex_encode() + + defp serialize_atomic(type, value) do + cond do + String.starts_with?(type, "bytes") -> value |> to_binary() |> Utils.hex_encode() + String.starts_with?(type, "uint") -> serialize_integer(value) + String.starts_with?(type, "int") -> serialize_integer(value) + end + end + + @spec to_binary(binary()) :: binary() + defp to_binary("0x" <> _ = hex), do: Utils.hex_decode!(hex) + defp to_binary(binary) when is_binary(binary), do: binary + + @spec serialize_integer(integer() | String.t()) :: String.t() + defp serialize_integer(value) when is_integer(value), do: Integer.to_string(value) + + defp serialize_integer("0x" <> _ = value), + do: value |> Utils.hex_to_integer!() |> Integer.to_string() + + defp serialize_integer(value) when is_binary(value), do: value + + @spec array_type?(String.t()) :: boolean() + defp array_type?(type), do: String.ends_with?(type, "]") + + @spec array_element_type(String.t()) :: String.t() + defp array_element_type(type) do + [_, element_type] = Regex.run(~r/^(.*)\[[^\]]*\]$/, type) + element_type + end +end diff --git a/lib/ethers/typed_data/domain.ex b/lib/ethers/typed_data/domain.ex new file mode 100644 index 00000000..f035260d --- /dev/null +++ b/lib/ethers/typed_data/domain.ex @@ -0,0 +1,118 @@ +defmodule Ethers.TypedData.Domain do + @moduledoc """ + The EIP-712 domain separator data (`EIP712Domain`). + + The domain scopes a signature to a particular application, contract and chain so that a + signature produced for one domain cannot be replayed in another. All five standard fields are + optional and only the fields that are actually present (non-nil) participate in the domain + type - this matches the behaviour of ethers.js / MetaMask and is required for interoperability. + + Standard fields (in canonical EIP-712 order) and their Solidity types: + + | Field | JSON name | Solidity type | + | -------------------- | ------------------- | ------------- | + | `:name` | `"name"` | `string` | + | `:version` | `"version"` | `string` | + | `:chain_id` | `"chainId"` | `uint256` | + | `:verifying_contract`| `"verifyingContract"`| `address` | + | `:salt` | `"salt"` | `bytes32` | + + ## Example + + Ethers.TypedData.Domain.new( + name: "Ether Mail", + version: "1", + chain_id: 1, + verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + ) + """ + + alias Ethers.TypedData.Field + + defstruct [:name, :version, :chain_id, :verifying_contract, :salt] + + @typedoc """ + An EIP-712 domain. Every field is optional (`nil` when absent). + + - `name` - human readable name of the signing domain (`string`). + - `version` - current version of the signing domain (`string`). + - `chain_id` - the EIP-155 chain id (`uint256`). + - `verifying_contract` - address of the contract that will verify the signature (`address`). + - `salt` - a disambiguating 32-byte salt (`bytes32`). + """ + @type t :: %__MODULE__{ + name: String.t() | nil, + version: String.t() | nil, + chain_id: non_neg_integer() | nil, + verifying_contract: Ethers.Types.t_address() | nil, + salt: binary() | nil + } + + # {struct field, JSON name, solidity type} in canonical EIP-712 order. + @ordered_fields [ + {:name, "name", "string"}, + {:version, "version", "string"}, + {:chain_id, "chainId", "uint256"}, + {:verifying_contract, "verifyingContract", "address"}, + {:salt, "salt", "bytes32"} + ] + + @doc """ + Creates a new `Ethers.TypedData.Domain` from a keyword list or a map. + + Accepts atom keys (`:chain_id`) or string keys (`"chain_id"`). An existing + `Ethers.TypedData.Domain` struct is returned unchanged. Unknown keys are ignored. + + ## Examples + + iex> Ethers.TypedData.Domain.new(name: "Ether Mail", version: "1", chain_id: 1) + %Ethers.TypedData.Domain{name: "Ether Mail", version: "1", chain_id: 1} + """ + @spec new(t() | map() | keyword()) :: t() + def new(%__MODULE__{} = domain), do: domain + + def new(fields) when is_list(fields), do: fields |> Map.new() |> new() + + def new(fields) when is_map(fields) do + %__MODULE__{ + name: fetch(fields, :name), + version: fetch(fields, :version), + chain_id: fetch(fields, :chain_id), + verifying_contract: fetch(fields, :verifying_contract), + salt: fetch(fields, :salt) + } + end + + @doc """ + Returns the present (non-nil) domain fields in canonical EIP-712 order as a list of + `Ethers.TypedData.Field` structs. + + Each returned `Field` uses the JSON field name (e.g. `"chainId"`) and its Solidity type. Only + fields that are set participate in the `EIP712Domain` type - absent fields are omitted. This + is consumed by both the JSON serializer and the hashing engine in later stages. + + ## Examples + + iex> Ethers.TypedData.Domain.new(name: "Ether Mail", chain_id: 1) + ...> |> Ethers.TypedData.Domain.present_fields() + [ + %Ethers.TypedData.Field{name: "name", type: "string"}, + %Ethers.TypedData.Field{name: "chainId", type: "uint256"} + ] + """ + @spec present_fields(t()) :: [Field.t()] + def present_fields(%__MODULE__{} = domain) do + for {key, json_name, solidity_type} <- @ordered_fields, + not is_nil(Map.fetch!(domain, key)) do + %Field{name: json_name, type: solidity_type} + end + end + + @spec fetch(map(), :name | :version | :chain_id | :verifying_contract | :salt) :: term() + defp fetch(fields, key) do + case Map.fetch(fields, key) do + {:ok, value} -> value + :error -> Map.get(fields, Atom.to_string(key)) + end + end +end diff --git a/lib/ethers/typed_data/encoder.ex b/lib/ethers/typed_data/encoder.ex new file mode 100644 index 00000000..552f5921 --- /dev/null +++ b/lib/ethers/typed_data/encoder.ex @@ -0,0 +1,251 @@ +defmodule Ethers.TypedData.Encoder do + @moduledoc false + + # The pure EIP-712 (https://eips.ethereum.org/EIPS/eip-712) encoding / hashing engine. + # + # This is the internal implementation of the EIP-712 algorithm (encodeType, typeHash, + # encodeData, hashStruct, the domain separator and the final signing digest). The public + # documented surface lives on `Ethers.TypedData`; this module holds the logic so it can be unit + # tested in isolation. + # + # All hashing uses keccak-256 (`Ethers.keccak_module/0`) and all atomic member words are produced + # with the same ABI.TypeEncoder machinery used elsewhere in the library, so encoding is + # byte-for-byte consistent with the ABI layer. + + alias Ethers.TypedData + alias Ethers.TypedData.Domain + alias Ethers.TypedData.Field + alias Ethers.Utils + + @typedoc "A 32-byte keccak-256 hash (binary)." + @type hash :: binary() + + @doc """ + Returns the EIP-712 `encodeType` string for `type_name`. + + The primary type is rendered first, followed by every transitively referenced struct type + sorted alphabetically by name. Each type renders as `Name(type1 field1,type2 field2,...)`. + + ## Example + + iex> td = 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"], + ...> message: %{} + ...> ) + iex> Ethers.TypedData.Encoder.encode_type(td, "Mail") + "Mail(Person from,Person to,string contents)Person(string name,address wallet)" + """ + @spec encode_type(TypedData.t(), String.t()) :: String.t() + def encode_type(%TypedData{types: types}, type_name) do + deps = collect_dependencies(types, type_name, MapSet.new()) + + sorted_deps = + deps + |> MapSet.delete(type_name) + |> MapSet.to_list() + |> Enum.sort() + + Enum.map_join([type_name | sorted_deps], &render_type(types, &1)) + end + + @doc """ + Returns `keccak256(encode_type(typed_data, type_name))` as a 32-byte binary. + """ + @spec type_hash(TypedData.t(), String.t()) :: hash() + def type_hash(%TypedData{} = typed_data, type_name) do + typed_data + |> encode_type(type_name) + |> keccak() + end + + @doc """ + Returns `typeHash ‖ word₁ ‖ word₂ ‖ …`, one 32-byte word per member of `type_name`. + + `value` must be a string-keyed map holding a value for each member of `type_name`. + """ + @spec encode_data(TypedData.t(), String.t(), map()) :: binary() + def encode_data(%TypedData{types: types} = typed_data, type_name, value) do + fields = Map.fetch!(types, type_name) + + words = + Enum.map_join(fields, fn %Field{name: name, type: type} -> + encode_field(type, Map.fetch!(value, name), typed_data) + end) + + type_hash(typed_data, type_name) <> words + end + + @doc """ + Returns `keccak256(encode_data(typed_data, type_name, value))` as a 32-byte binary. + """ + @spec hash_struct(TypedData.t(), String.t(), map()) :: hash() + def hash_struct(%TypedData{} = typed_data, type_name, value) do + typed_data + |> encode_data(type_name, value) + |> keccak() + end + + @doc """ + Returns the EIP-712 domain separator (`hashStruct("EIP712Domain", domain)`) as a 32-byte binary. + + Only the present (non-nil) domain fields participate, in canonical EIP-712 order. + """ + @spec domain_separator(TypedData.t()) :: hash() + def domain_separator(%TypedData{domain: %Domain{} = domain}) do + fields = Domain.present_fields(domain) + + value = + Map.new(fields, fn %Field{name: name} -> {name, domain_field_value(domain, name)} end) + + domain_typed_data = %TypedData{ + domain: domain, + types: %{"EIP712Domain" => fields}, + primary_type: "EIP712Domain", + message: value + } + + hash_struct(domain_typed_data, "EIP712Domain", value) + end + + @doc """ + Returns the EIP-712 signing digest of `typed_data`. + + This is `keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(primaryType, message))`. + + ## Parameters + + - `typed_data` - the `Ethers.TypedData` to hash. + - `format` - either `:bin` (default, returns the raw 32-byte binary) or `:hex` (returns a + `0x`-prefixed hex string). + """ + @spec hash(TypedData.t()) :: hash() + @spec hash(TypedData.t(), :bin | :hex) :: binary() | String.t() + def hash(typed_data, format \\ :bin) + + def hash(%TypedData{} = typed_data, :bin) do + domain_separator = domain_separator(typed_data) + struct_hash = hash_struct(typed_data, typed_data.primary_type, typed_data.message) + + keccak(<<0x19, 0x01>> <> domain_separator <> struct_hash) + end + + def hash(%TypedData{} = typed_data, :hex) do + typed_data + |> hash(:bin) + |> Utils.hex_encode() + end + + # --- internal helpers ----------------------------------------------------- + + # Transitively collects the set of struct type names referenced from `type_name` (inclusive). + @spec collect_dependencies(map(), String.t(), MapSet.t()) :: MapSet.t() + defp collect_dependencies(types, type_name, acc) do + if MapSet.member?(acc, type_name) do + acc + else + acc = MapSet.put(acc, type_name) + + types + |> Map.fetch!(type_name) + |> Enum.reduce(acc, &collect_field_dependencies(types, &1, &2)) + end + end + + @spec collect_field_dependencies(map(), Field.t(), MapSet.t()) :: MapSet.t() + defp collect_field_dependencies(types, %Field{type: type}, acc) do + base = base_type(type) + + if Map.has_key?(types, base) do + collect_dependencies(types, base, acc) + else + acc + end + end + + @spec render_type(map(), String.t()) :: String.t() + defp render_type(types, type_name) do + members = + types + |> Map.fetch!(type_name) + |> Enum.map_join(",", fn %Field{name: name, type: type} -> "#{type} #{name}" end) + + "#{type_name}(#{members})" + end + + # Encodes a single member to its 32-byte word (or the intermediate hash for arrays/references). + @spec encode_field(String.t(), term(), TypedData.t()) :: binary() + defp encode_field(type, value, %TypedData{types: types} = typed_data) do + cond do + array_type?(type) -> + element_type = element_type(type) + + value + |> Enum.map_join(fn element -> encode_field(element_type, element, typed_data) end) + |> keccak() + + Map.has_key?(types, base_type(type)) -> + hash_struct(typed_data, base_type(type), value) + + true -> + encode_atomic(type, value) + end + end + + @spec encode_atomic(String.t(), term()) :: binary() + defp encode_atomic(type, value) do + case ABI.FunctionSelector.decode_type(type) do + :string -> + keccak(value) + + :bytes -> + keccak(normalize_binary(value)) + + {:bytes, _size} = type_tuple -> + encode_word(normalize_binary(value), type_tuple) + + type_tuple -> + encode_word(Utils.prepare_arg(value, type_tuple), type_tuple) + end + end + + @spec encode_word(term(), ABI.FunctionSelector.type()) :: binary() + defp encode_word(prepared, type_tuple) do + ABI.TypeEncoder.encode([prepared], [type_tuple]) + end + + # Normalizes a `bytes`/`bytesN` value: `0x`-hex strings are decoded, raw binaries pass through. + @spec normalize_binary(binary()) :: binary() + defp normalize_binary(<<"0x", _rest::binary>> = hex), do: Utils.hex_decode!(hex) + defp normalize_binary(binary) when is_binary(binary), do: binary + + @spec domain_field_value(Domain.t(), String.t()) :: binary() | non_neg_integer() | nil + defp domain_field_value(%Domain{} = domain, "name"), do: domain.name + defp domain_field_value(%Domain{} = domain, "version"), do: domain.version + defp domain_field_value(%Domain{} = domain, "chainId"), do: domain.chain_id + defp domain_field_value(%Domain{} = domain, "verifyingContract"), do: domain.verifying_contract + defp domain_field_value(%Domain{} = domain, "salt"), do: domain.salt + + # True when `type` has an array suffix (`T[]` or `T[n]`). + @spec array_type?(String.t()) :: boolean() + defp array_type?(type), do: Regex.match?(~r/\[\d*\]$/, type) + + # Removes ONE array-suffix level (`T[][]` -> `T[]`, `T[n]` -> `T`). + @spec element_type(String.t()) :: String.t() + defp element_type(type), do: Regex.replace(~r/\[\d*\]$/, type, "") + + # Strips ALL array suffixes returning the base struct/atomic name. + @spec base_type(String.t()) :: String.t() + defp base_type(type), do: type |> String.split("[", parts: 2) |> hd() + + @spec keccak(binary()) :: hash() + defp keccak(binary), do: Ethers.keccak_module().hash_256(binary) +end diff --git a/lib/ethers/typed_data/field.ex b/lib/ethers/typed_data/field.ex new file mode 100644 index 00000000..9a49af31 --- /dev/null +++ b/lib/ethers/typed_data/field.ex @@ -0,0 +1,51 @@ +defmodule Ethers.TypedData.Field do + @moduledoc """ + A single member of an EIP-712 struct type. + + A field pairs a member `name` with its Solidity `type` string (e.g. `"string"`, + `"address"`, `"uint256"`, `"Person"`, `"Person[]"`). The ordered list of fields for a + given struct type is what `encodeType`/`hashStruct` operate on in the EIP-712 algorithm. + + Both `name` and `type` are plain strings using the JSON/Solidity spelling (for example the + domain's chain id field is named `"chainId"` with type `"uint256"`). + """ + + @enforce_keys [:name, :type] + defstruct [:name, :type] + + @typedoc """ + An EIP-712 struct member. + + - `name` - the member name as it appears in the message (e.g. `"wallet"`, `"chainId"`). + - `type` - the Solidity type string of the member (e.g. `"address"`, `"Person[]"`). + """ + @type t :: %__MODULE__{ + name: String.t(), + type: String.t() + } + + @doc """ + Creates a new `Ethers.TypedData.Field` from a map, keyword list, or an existing field. + + Accepts atom (`:name`/`:type`) or string (`"name"`/`"type"`) keys. An existing + `Ethers.TypedData.Field` is returned unchanged. + + ## Examples + + iex> Ethers.TypedData.Field.new(%{name: "wallet", type: "address"}) + %Ethers.TypedData.Field{name: "wallet", type: "address"} + + iex> Ethers.TypedData.Field.new(%{"name" => "contents", "type" => "string"}) + %Ethers.TypedData.Field{name: "contents", type: "string"} + """ + @spec new(t() | map() | keyword()) :: t() + def new(%__MODULE__{} = field), do: field + + def new(field) when is_list(field), do: field |> Map.new() |> new() + + def new(%{name: name, type: type}), + do: %__MODULE__{name: to_string(name), type: to_string(type)} + + def new(%{"name" => name, "type" => type}), + do: %__MODULE__{name: to_string(name), type: to_string(type)} +end diff --git a/lib/ethers/typed_data/schema.ex b/lib/ethers/typed_data/schema.ex new file mode 100644 index 00000000..5bc8e242 --- /dev/null +++ b/lib/ethers/typed_data/schema.ex @@ -0,0 +1,342 @@ +defmodule Ethers.TypedData.Schema do + @moduledoc """ + Compile-time DSL for declaring [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed-data + struct types as native Elixir modules. + + Instead of hand-writing the `types`/`primary_type`/`message` maps that `Ethers.TypedData.new/1` + consumes, you declare each EIP-712 struct type as an Elixir module and build an + `Ethers.TypedData` straight from struct instances with `Ethers.TypedData.new/2` (or + `new!/2`). This is a thin front-end over the existing engine - the resulting payload hashes and + serializes identically to the map-based form. + + ## Usage + + `use Ethers.TypedData.Schema` imports the `typed_schema/1,2` and `field/2,3` macros. A + `typed_schema` block declares an ordered list of fields; the macro generates a matching + `defstruct` plus an introspection function `__typed_data_schema__/0`. + + 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_schema/1` derives the EIP-712 type name from the module's last segment + (`MyApp.Messages.Mail` -> `"Mail"`); `typed_schema/2` takes an explicit name string. + + ## Field types + + The second argument to `field/2,3` is the member's type, in one of these forms: + + - a **schema module** reference (`Person`) - another module that `use`s this DSL. The EIP-712 + type string is that schema's declared type name, and it is registered (recursively) in the + generated `types` map. + - an **atom** (`:string`, `:address`, `:uint256`) - an atomic Solidity type; the type string is + `Atom.to_string/1` of it. + - a **string** (`"uint256"`, `"bytes32"`) - a literal EIP-712 type string. + - `{:array, inner}` - a dynamic array of `inner` (`"[]"`). + - `{:array, inner, n}` - a fixed-size array of `inner` (`"[n]"`). + + Field **order matters** - EIP-712 `encodeType` is order-sensitive, so the sequence of + `field/2,3` calls is the source of truth (which is why the block also generates the + `defstruct`). Type references (a schema module atom such as `Person`) are stored **unresolved** + and resolved at runtime by the expander, not at macro-expansion time - so mutually referencing + modules do not create compile-ordering problems. + + ## Building typed data from a schema + + Given the `Person`/`Mail` schemas above, build and hash the canonical EIP-712 `Mail` message. + (These example modules ship in the test suite as `Ethers.Support.EIP712.Person` and + `Ethers.Support.EIP712.Mail`.) + + iex> alias Ethers.Support.EIP712.{Mail, Person} + iex> mail = %Mail{ + ...> from: %Person{name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, + ...> to: %Person{name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, + ...> contents: "Hello, Bob!" + ...> } + iex> td = + ...> Ethers.TypedData.new!(mail, + ...> domain: [ + ...> name: "Ether Mail", + ...> version: "1", + ...> chain_id: 1, + ...> verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + ...> ] + ...> ) + iex> Ethers.TypedData.encode_type(td, "Mail") + "Mail(Person from,Person to,string contents)Person(string name,address wallet)" + iex> Ethers.TypedData.hash(td, :hex) + "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + + ## Introspection contract + + The generated `__typed_data_schema__/0` returns: + + {type_name :: String.t(), [%{key: atom(), name: String.t(), type: term()}]} + + where each `type` term is the raw declared type: a module atom (`Person`), an atom + (`:string`), a string (`"uint256"`), or `{:array, inner}` / `{:array, inner, n}`. The + per-field `:default` (used only for `defstruct`) is intentionally dropped from this tuple. + """ + + alias Ethers.TypedData.Field + + @fields_attribute :ethers_typed_data_fields + + @doc false + defmacro __using__(_opts) do + quote do + import Ethers.TypedData.Schema, only: [typed_schema: 1, typed_schema: 2, field: 2, field: 3] + end + end + + @doc """ + Declares an EIP-712 typed-data schema. + + `typed_schema/1` derives the EIP-712 type name from the module's last segment + (`MyApp.Messages.Mail` -> `"Mail"`). `typed_schema/2` takes an explicit type-name string. + + The `do` block must contain one or more `field/2,3` declarations, in order. The macro emits a + `defstruct` and the `__typed_data_schema__/0` introspection function. + """ + defmacro typed_schema(type_name \\ nil, do: block) do + quote do + Module.put_attribute(__MODULE__, unquote(@fields_attribute), []) + + unquote(block) + + @ethers_typed_data_schema unquote(__MODULE__).__schema__( + __MODULE__, + unquote(type_name) + ) + + @ethers_typed_data_struct_fields Enum.map( + Module.get_attribute( + __MODULE__, + unquote(@fields_attribute) + ), + fn field -> {field.key, field.default} end + ) + + defstruct @ethers_typed_data_struct_fields + + @spec __typed_data_schema__() :: + {String.t(), [%{key: atom(), name: String.t(), type: term()}]} + def __typed_data_schema__, do: @ethers_typed_data_schema + end + end + + @doc """ + Declares a single field of the enclosing `typed_schema`. + + - `key` - the struct key (must be an atom). Declaring the same `key` twice raises. + - `type` - the raw, unresolved type term: a schema module atom (`Person`), an atom + (`:string`, `:uint256`), a type string (`"uint256"`), or `{:array, inner}` / + `{:array, inner, n}`. + - `opts`: + - `:name` - the EIP-712 member name (defaults to `to_string(key)`). + - `:default` - the `defstruct` default for this key (defaults to `nil`). + """ + defmacro field(key, type, opts \\ []) do + quote do + unquote(__MODULE__).__field__( + __MODULE__, + unquote(key), + unquote(type), + unquote(opts) + ) + end + end + + @doc false + @spec __field__(module(), atom(), term(), keyword()) :: :ok + def __field__(module, key, type, opts) do + unless is_atom(key) do + raise ArgumentError, "field key must be an atom, got: #{inspect(key)}" + end + + fields = Module.get_attribute(module, @fields_attribute) || [] + + if Enum.any?(fields, fn field -> field.key == key end) do + raise ArgumentError, "field #{inspect(key)} is already declared in #{inspect(module)}" + end + + meta = %{ + key: key, + name: opts[:name] || to_string(key), + type: type, + default: Keyword.get(opts, :default) + } + + Module.put_attribute(module, @fields_attribute, fields ++ [meta]) + + :ok + end + + @doc false + @spec __schema__(module(), String.t() | nil) :: + {String.t(), [%{key: atom(), name: String.t(), type: term()}]} + def __schema__(module, type_name) do + fields = Module.get_attribute(module, @fields_attribute) || [] + + if fields == [] do + raise ArgumentError, + "typed_schema for #{inspect(module)} must declare at least one field" + end + + name = type_name || module |> Module.split() |> List.last() + + meta = Enum.map(fields, fn field -> %{key: field.key, name: field.name, type: field.type} end) + + {name, meta} + end + + @typep type_term :: + atom() + | String.t() + | {:array, type_term()} + | {:array, type_term(), pos_integer()} + @typep field_meta :: %{key: atom(), name: String.t(), type: type_term()} + @typep schema :: {String.t(), [field_meta()]} + @typep types_acc :: %{types: %{String.t() => [Field.t()]}, modules: %{String.t() => module()}} + + @doc """ + Expands a schema struct instance into the parameters consumed by `Ethers.TypedData.new/1`. + + Walks the schema module referenced by `struct` transitively, resolving every referenced type + into the EIP-712 `types` map and building the string-keyed `message` from the struct instance. + + Returns `%{types:, primary_type:, message:, domain:}` ready to hand to `Ethers.TypedData.new/1`. + + Raises `ArgumentError` if `struct` is not a schema module (does not export + `__typed_data_schema__/0`), if a referenced type is not a schema module, or if two distinct + modules resolve to the same EIP-712 type name with different fields. + """ + @spec to_params(struct(), keyword()) :: %{ + types: %{String.t() => [Field.t()]}, + primary_type: String.t(), + message: %{String.t() => term()}, + domain: keyword() | map() + } + def to_params(struct, opts) when is_struct(struct) do + module = struct.__struct__ + {primary_type, _fields} = schema_of!(module) + + %{types: types} = build_types(module, %{types: %{}, modules: %{}}) + + %{ + types: types, + primary_type: primary_type, + message: build_message(module, struct), + domain: opts[:domain] || %{} + } + end + + @spec schema_of!(module()) :: schema() + defp schema_of!(module) do + if is_atom(module) and Code.ensure_loaded?(module) and + function_exported?(module, :__typed_data_schema__, 0) do + module.__typed_data_schema__() + else + raise ArgumentError, "not an Ethers.TypedData schema: #{inspect(module)}" + end + end + + @spec build_types(module(), types_acc()) :: types_acc() + defp build_types(module, acc) do + {type_name, fields} = schema_of!(module) + + field_structs = + Enum.map(fields, fn field -> + %Field{name: field.name, type: eip_type_string(field.type)} + end) + + case Map.get(acc.types, type_name) do + nil -> + acc = %{ + acc + | types: Map.put(acc.types, type_name, field_structs), + modules: Map.put(acc.modules, type_name, module) + } + + Enum.reduce(fields, acc, fn field, acc -> register_refs(field.type, acc) end) + + ^field_structs -> + # Already registered (recursion dedup, or an identical duplicate declaration). + acc + + _other -> + raise ArgumentError, + "EIP-712 type name collision for #{inspect(type_name)}: " <> + "#{inspect(Map.get(acc.modules, type_name))} and #{inspect(module)} declare " <> + "different fields under the same type name" + end + end + + @spec register_refs(term(), types_acc()) :: types_acc() + defp register_refs({:array, inner}, acc), do: register_refs(inner, acc) + defp register_refs({:array, inner, _n}, acc), do: register_refs(inner, acc) + + defp register_refs(type, acc) do + if module_ref?(type), do: build_types(type, acc), else: acc + end + + @spec eip_type_string(type_term()) :: String.t() + defp eip_type_string({:array, inner}), do: eip_type_string(inner) <> "[]" + defp eip_type_string({:array, inner, n}), do: eip_type_string(inner) <> "[#{n}]" + defp eip_type_string(type) when is_binary(type), do: type + + defp eip_type_string(type) when is_atom(type) do + if module_ref?(type) do + {type_name, _fields} = schema_of!(type) + type_name + else + Atom.to_string(type) + end + end + + @spec module_ref?(term()) :: boolean() + defp module_ref?(type) when is_atom(type) and type not in [nil, true, false] do + String.starts_with?(Atom.to_string(type), "Elixir.") + end + + defp module_ref?(_type), do: false + + @spec build_message(module(), struct()) :: %{String.t() => term()} + defp build_message(module, struct) do + {_type_name, fields} = schema_of!(module) + + Map.new(fields, fn %{key: key, name: name, type: type} -> + {name, to_message_value(type, Map.get(struct, key))} + end) + end + + @spec to_message_value(term(), term()) :: term() + defp to_message_value({:array, inner}, list) when is_list(list), + do: Enum.map(list, &to_message_value(inner, &1)) + + defp to_message_value({:array, inner, _n}, list) when is_list(list), + do: Enum.map(list, &to_message_value(inner, &1)) + + defp to_message_value(type, value) do + if module_ref?(type) and is_struct(value) do + build_message(type, value) + else + value + end + end +end diff --git a/mix.exs b/mix.exs index b27d0e74..6ada0a0c 100644 --- a/mix.exs +++ b/mix.exs @@ -69,6 +69,7 @@ defmodule Ethers.MixProject do "README.md": [title: "Introduction"], "CHANGELOG.md": [title: "Changelog"], "guides/typed-arguments.md": [title: "Typed Arguments"], + "guides/eip-712.md": [title: "EIP-712 Typed Data"], "guides/configuration.md": [title: "Configuration"], "guides/upgrading.md": [title: "Upgrading"] ], @@ -92,6 +93,12 @@ defmodule Ethers.MixProject do ~r/^Ethers\.Signer\.[A-Za-z0-9.]+$/, ~r/^Ethers\.Signer$/ ], + "Typed Data": [ + "Ethers.TypedData", + "Ethers.TypedData.Schema", + "Ethers.TypedData.Domain", + "Ethers.TypedData.Field" + ], "Builtin Contract Errors": [ ~r/^Ethers\.Contracts\..*$/ ] diff --git a/test/ethers/signer/json_rpc_test.exs b/test/ethers/signer/json_rpc_test.exs index 71e81002..fe13d6ba 100644 --- a/test/ethers/signer/json_rpc_test.exs +++ b/test/ethers/signer/json_rpc_test.exs @@ -43,6 +43,80 @@ defmodule Ethers.Signer.JsonRPCTest do end end + describe "sign_typed_data/2" do + test "returns a well-formed 65-byte signature for the default account" do + 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: 31_337, + verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + ], + message: %{ + "from" => %{ + "name" => "Cow", + "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to" => %{ + "name" => "Bob", + "wallet" => "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents" => "Hello, Bob!" + } + ) + + assert {:ok, signature} = + Signer.JsonRPC.sign_typed_data(typed_data, + from: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ) + + assert <<"0x", hex::binary-size(130)>> = signature + assert String.match?(hex, ~r/^[0-9a-fA-F]{130}$/) + end + + test "fails signing with an unknown from address" do + typed_data = + Ethers.TypedData.new!( + types: %{ + "Mail" => [%{name: "contents", type: "string"}] + }, + primary_type: "Mail", + domain: [name: "Ether Mail", version: "1", chain_id: 31_337], + message: %{"contents" => "Hello, Bob!"} + ) + + assert {:error, _reason} = + Signer.JsonRPC.sign_typed_data(typed_data, + from: "0xbba94ef8bd5ffee41947b4585a84bda5a3d3da6e" + ) + end + + test "returns an error tuple (not a raise) when :from is missing" do + typed_data = + Ethers.TypedData.new!( + types: %{"Mail" => [%{name: "contents", type: "string"}]}, + primary_type: "Mail", + domain: [name: "Ether Mail", version: "1", chain_id: 31_337], + message: %{"contents" => "Hello, Bob!"} + ) + + assert {:error, :missing_from_address} = Signer.JsonRPC.sign_typed_data(typed_data, []) + end + end + describe "accounts/1" do test "returns account list" do assert {:ok, accounts} = Signer.JsonRPC.accounts([]) diff --git a/test/ethers/signer/local_test.exs b/test/ethers/signer/local_test.exs index b23e4183..27f11cc4 100644 --- a/test/ethers/signer/local_test.exs +++ b/test/ethers/signer/local_test.exs @@ -26,6 +26,85 @@ defmodule Ethers.Signer.LocalTest do end end + describe "sign_typed_data/2" do + # The canonical EIP-712 Mail/Person payload. Its signing digest is the well-known + # 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2 from the spec. + defp mail_typed_data(chain_id) do + 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: chain_id, + verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + ], + message: %{ + "from" => %{"name" => "Cow", "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, + "to" => %{"name" => "Bob", "wallet" => "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, + "contents" => "Hello, Bob!" + } + ) + end + + test "produces the exact signature for a fixed key and payload" do + # Expected signature produced independently by ethers.js v6.17.0 for the same + # private key (@private_key -> 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1) and the + # canonical Mail payload (domain chainId 1): + # const wallet = new ethers.Wallet(pk) + # await wallet.signTypedData(domain, types, message) + # ethers.js digest matched the spec value 0xbe609aee343fb3c4b...30957bd2. + expected = + "0x12bdd486cb42c3b3c414bb04253acfe7d402559e7637562987af6bd78508f386" <> + "23c1cc09880613762cc913d49fd7d3c091be974c0dee83fb233300b6b58727311c" + + assert {:ok, ^expected} = + Signer.Local.sign_typed_data(mail_typed_data(1), private_key: @private_key) + end + + test "returns :wrong_key when :from does not match the private key" do + assert {:error, :wrong_key} = + Signer.Local.sign_typed_data(mail_typed_data(1), + private_key: @private_key, + from: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ) + end + + test "matches anvil's eth_signTypedData_v4 byte-for-byte" do + # anvil default account #0 and its published private key. + from = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + + anvil_private_key = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + + # Domain chain_id must match anvil (31337) so it accepts the payload. No fixed-bytesN + # members are used (anvil bytes4 signing bug, foundry#5803). + typed_data = mail_typed_data(31_337) + + assert {:ok, local_signature} = + Signer.Local.sign_typed_data(typed_data, + private_key: anvil_private_key, + from: from + ) + + assert {:ok, anvil_signature} = + Signer.JsonRPC.sign_typed_data(typed_data, from: from) + + # Deterministic ECDSA over the same digest => identical r || s || v. + assert local_signature == anvil_signature + end + end + describe "accounts/1" do test "returns the correct address for a given private key as binary" do key = diff --git a/test/ethers/typed_data/encoder_test.exs b/test/ethers/typed_data/encoder_test.exs new file mode 100644 index 00000000..2ae1bc63 --- /dev/null +++ b/test/ethers/typed_data/encoder_test.exs @@ -0,0 +1,262 @@ +defmodule Ethers.TypedData.EncoderTest do + use ExUnit.Case, async: true + + alias Ethers.TypedData + alias Ethers.TypedData.Encoder + alias Ethers.Utils + + doctest Ethers.TypedData.Encoder + + defp hex(bin), do: Utils.hex_encode(bin) + + describe "canonical EIP-712 Mail example" do + # Vectors published in the EIP-712 specification (the Mail/Person example): + # https://eips.ethereum.org/EIPS/eip-712 (see the "Specification of the Example" and the + # reference implementation `assets/eip-712/Example.js`). + setup do + td = + 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!" + } + ) + + %{td: td} + end + + test "encode_type/2 matches the spec", %{td: td} do + assert Encoder.encode_type(td, "Mail") == + "Mail(Person from,Person to,string contents)Person(string name,address wallet)" + + assert Encoder.encode_type(td, "Person") == "Person(string name,address wallet)" + end + + test "type_hash/2 matches the spec (full 32 bytes)", %{td: td} do + type_hash = Encoder.type_hash(td, "Mail") + assert byte_size(type_hash) == 32 + + assert hex(type_hash) == + "0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2" + end + + test "hash_struct/3 of the message matches the spec", %{td: td} do + assert hex(Encoder.hash_struct(td, "Mail", td.message)) == + "0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e" + end + + test "domain_separator/1 matches the spec", %{td: td} do + assert hex(Encoder.domain_separator(td)) == + "0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" + end + + test "hash/1 (signing digest) matches the spec", %{td: td} do + digest = Encoder.hash(td) + assert byte_size(digest) == 32 + + assert hex(digest) == + "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + + # hash/2 hex output must agree with the binary form. + assert Encoder.hash(td, :hex) == hex(digest) + assert Encoder.hash(td, :bin) == digest + end + end + + describe "nested array-of-structs vector (ethers.js cross-check)" do + # Independently produced with ethers.js v6.17.0 (`TypedDataEncoder`). This exercises an + # array of structs (`Person[]`) and nested dynamic `address[]` arrays. + # + # const typesA = { + # Person: [{name:"name",type:"string"}, {name:"wallets",type:"address[]"}], + # Mail: [{name:"from",type:"Person"}, {name:"to",type:"Person[]"}, {name:"contents",type:"string"}] + # }; + # TypedDataEncoder.from(typesA).encodeType("Mail") + # TypedDataEncoder.from(typesA).hashStruct("Mail", valueA) + # TypedDataEncoder.hashDomain(domainA) + # TypedDataEncoder.hash(domainA, typesA, valueA) + setup do + td = + TypedData.new!( + types: %{ + "Person" => [ + %{name: "name", type: "string"}, + %{name: "wallets", 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", + "wallets" => [ + "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826", + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ] + }, + "to" => [ + %{ + "name" => "Bob", + "wallets" => [ + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0xb0b0b0b0b0b0b000000000000000000000000000" + ] + } + ], + "contents" => "Hello, Bob!" + } + ) + + %{td: td} + end + + test "encode_type/2", %{td: td} do + assert Encoder.encode_type(td, "Mail") == + "Mail(Person from,Person[] to,string contents)Person(string name,address[] wallets)" + end + + test "hash_struct/3", %{td: td} do + assert hex(Encoder.hash_struct(td, "Mail", td.message)) == + "0xb1ae2bdff9f450cb829d4fea584e0407485af89480e6e1a6e493628bddfb106b" + end + + test "domain_separator/1", %{td: td} do + assert hex(Encoder.domain_separator(td)) == + "0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" + end + + test "hash/1 digest", %{td: td} do + assert Encoder.hash(td, :hex) == + "0xc26a267e7e449d3a8df39c1476d8e8f27216d86d49a9e1d3d096c9e64670c821" + end + end + + describe "mixed atomic types + partial domain vector (ethers.js cross-check)" do + # Independently produced with ethers.js v6.17.0. Exercises a negative `int256`, dynamic + # `bytes`, fixed `bytes32`, `string`, `uint8`, and a domain with only a subset of fields + # present (`name` + `chainId`). + # + # const domainB = { name: "Test", chainId: 5 }; + # const typesB = { Order: [ + # {name:"amount",type:"int256"}, {name:"data",type:"bytes"}, {name:"hash",type:"bytes32"}, + # {name:"note",type:"string"}, {name:"count",type:"uint8"} ] }; + # const valueB = { amount:"-12345", data:"0xdeadbeef", + # hash:"0x00000000000000000000000000000000000000000000000000000000000000ab", + # note:"hi", count:7 }; + setup do + td = + TypedData.new!( + types: %{ + "Order" => [ + %{name: "amount", type: "int256"}, + %{name: "data", type: "bytes"}, + %{name: "hash", type: "bytes32"}, + %{name: "note", type: "string"}, + %{name: "count", type: "uint8"} + ] + }, + primary_type: "Order", + domain: [name: "Test", chain_id: 5], + message: %{ + "amount" => -12_345, + "data" => "0xdeadbeef", + "hash" => "0x00000000000000000000000000000000000000000000000000000000000000ab", + "note" => "hi", + "count" => 7 + } + ) + + %{td: td} + end + + test "encode_type/2", %{td: td} do + assert Encoder.encode_type(td, "Order") == + "Order(int256 amount,bytes data,bytes32 hash,string note,uint8 count)" + end + + test "hash_struct/3 (negative int, dynamic bytes, bytes32)", %{td: td} do + assert hex(Encoder.hash_struct(td, "Order", td.message)) == + "0xa3da230a39885569047b39e757b096a309aef43468f866292ea0c6ffebca3a5b" + end + + test "domain_separator/1 with only a subset of domain fields present", %{td: td} do + assert hex(Encoder.domain_separator(td)) == + "0x94773a93ac27b9c9c9e55543f5e5d81bf36a002890f81833375e3f5010b9505a" + end + + test "hash/1 digest", %{td: td} do + assert Encoder.hash(td, :hex) == + "0xbd5e3074d223a9171a21731fcff08c06ea194bc06749daae36849cf69c1a0731" + end + end + + describe "value-form equivalence" do + # `address` and `bytesN` members must encode identically whether supplied as a `0x` hex string + # or as a raw binary. Cross-checked structurally (the hex-string forms are already pinned to + # ethers.js in the vectors above). + test "address given as 20-byte binary equals the hex-string form" do + raw_addr = Base.decode16!("b0b0b0b0b0b0b000000000000000000000000000", case: :lower) + + td_hex = order_td("0x00000000000000000000000000000000000000000000000000000000000000ab") + td_bin = order_td(Base.decode16!(String.duplicate("0", 62) <> "ab", case: :lower)) + + # bytes32 hex-string vs raw-binary equivalence + assert Encoder.hash_struct(td_hex, "Thing", td_hex.message) == + Encoder.hash_struct(td_bin, "Thing", td_bin.message) + + # address hex-string vs raw-binary equivalence + td_addr_hex = addr_td("0xb0b0b0b0b0b0b000000000000000000000000000") + td_addr_bin = addr_td(raw_addr) + + assert Encoder.hash_struct(td_addr_hex, "Acct", td_addr_hex.message) == + Encoder.hash_struct(td_addr_bin, "Acct", td_addr_bin.message) + end + + defp order_td(hash_value) do + TypedData.new!( + types: %{"Thing" => [%{name: "hash", type: "bytes32"}]}, + primary_type: "Thing", + domain: [name: "T"], + message: %{"hash" => hash_value} + ) + end + + defp addr_td(addr_value) do + TypedData.new!( + types: %{"Acct" => [%{name: "owner", type: "address"}]}, + primary_type: "Acct", + domain: [name: "T"], + message: %{"owner" => addr_value} + ) + end + end +end diff --git a/test/ethers/typed_data/schema_expansion_test.exs b/test/ethers/typed_data/schema_expansion_test.exs new file mode 100644 index 00000000..230a4fe3 --- /dev/null +++ b/test/ethers/typed_data/schema_expansion_test.exs @@ -0,0 +1,228 @@ +defmodule Ethers.TypedData.SchemaExpansionTest.Person do + use Ethers.TypedData.Schema + + typed_schema "Person" do + field(:name, :string) + field(:wallet, :address) + end +end + +defmodule Ethers.TypedData.SchemaExpansionTest.Mail do + use Ethers.TypedData.Schema + + alias Ethers.TypedData.SchemaExpansionTest.Person + + typed_schema "Mail" do + field(:from, Person) + field(:to, Person) + field(:contents, :string) + end +end + +defmodule Ethers.TypedData.SchemaExpansionTest.Group do + use Ethers.TypedData.Schema + + alias Ethers.TypedData.SchemaExpansionTest.Person + + typed_schema "Group" do + field(:members, {:array, Person}) + field(:group_name, :string, name: "groupName") + end +end + +defmodule Ethers.TypedData.SchemaExpansionTest.Receipt do + use Ethers.TypedData.Schema + + typed_schema "Receipt" do + field(:payer, :address) + field(:amount, :uint256) + field(:ref, "bytes32") + field(:memo, :string) + end +end + +defmodule Ethers.TypedData.SchemaExpansionTest.NotASchema do + defstruct [:foo] +end + +defmodule Ethers.TypedData.SchemaExpansionTest do + use ExUnit.Case, async: true + + alias Ethers.TypedData + alias Ethers.TypedData.SchemaExpansionTest.Group + alias Ethers.TypedData.SchemaExpansionTest.Mail + alias Ethers.TypedData.SchemaExpansionTest.NotASchema + alias Ethers.TypedData.SchemaExpansionTest.Person + alias Ethers.TypedData.SchemaExpansionTest.Receipt + + @cow_wallet "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + @bob_wallet "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + + @domain [ + name: "Ether Mail", + version: "1", + chain_id: 1, + verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + ] + + defp mail_struct do + %Mail{ + from: %Person{name: "Cow", wallet: @cow_wallet}, + to: %Person{name: "Bob", wallet: @bob_wallet}, + contents: "Hello, Bob!" + } + end + + defp hand_built_mail do + 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: @domain, + message: %{ + "from" => %{"name" => "Cow", "wallet" => @cow_wallet}, + "to" => %{"name" => "Bob", "wallet" => @bob_wallet}, + "contents" => "Hello, Bob!" + } + ) + end + + describe "digest oracle (canonical Mail)" do + test "struct-built Mail hashes to the EIP-712 spec digest" do + td = TypedData.new!(mail_struct(), domain: @domain) + + assert TypedData.hash(td, :hex) == + "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + end + + test "struct-built Mail matches the hand-built TypedData across the engine" do + struct_built = TypedData.new!(mail_struct(), domain: @domain) + hand_built = hand_built_mail() + + assert TypedData.encode_type(struct_built, "Mail") == + TypedData.encode_type(hand_built, "Mail") + + assert TypedData.encode_type(struct_built, "Mail") == + "Mail(Person from,Person to,string contents)Person(string name,address wallet)" + + assert TypedData.domain_separator(struct_built) == TypedData.domain_separator(hand_built) + assert TypedData.hash(struct_built) == TypedData.hash(hand_built) + assert TypedData.to_eip712_json(struct_built) == TypedData.to_eip712_json(hand_built) + end + end + + describe "nested array of structs" do + test "resolves {:array, Person} to Person[] and registers Person" do + group = %Group{ + members: [ + %Person{name: "Cow", wallet: @cow_wallet}, + %Person{name: "Bob", wallet: @bob_wallet} + ], + group_name: "Farm" + } + + td = TypedData.new!(group, domain: @domain) + + assert Map.has_key?(td.types, "Person") + + [members_field, _group_name_field] = td.types["Group"] + assert members_field.type == "Person[]" + + hand_built = + TypedData.new!( + types: %{ + "Person" => [ + %{name: "name", type: "string"}, + %{name: "wallet", type: "address"} + ], + "Group" => [ + %{name: "members", type: "Person[]"}, + %{name: "groupName", type: "string"} + ] + }, + primary_type: "Group", + domain: @domain, + message: %{ + "members" => [ + %{"name" => "Cow", "wallet" => @cow_wallet}, + %{"name" => "Bob", "wallet" => @bob_wallet} + ], + "groupName" => "Farm" + } + ) + + assert TypedData.hash(td) == TypedData.hash(hand_built) + end + end + + describe "atomic type variety" do + test "resolves :address, :uint256, string \"bytes32\" and :string forms identically" do + receipt = %Receipt{ + payer: @cow_wallet, + amount: 1000, + ref: "0x" <> String.duplicate("ab", 32), + memo: "thanks" + } + + td = TypedData.new!(receipt, domain: @domain) + + hand_built = + TypedData.new!( + types: %{ + "Receipt" => [ + %{name: "payer", type: "address"}, + %{name: "amount", type: "uint256"}, + %{name: "ref", type: "bytes32"}, + %{name: "memo", type: "string"} + ] + }, + primary_type: "Receipt", + domain: @domain, + message: %{ + "payer" => @cow_wallet, + "amount" => 1000, + "ref" => "0x" <> String.duplicate("ab", 32), + "memo" => "thanks" + } + ) + + assert Enum.map(td.types["Receipt"], & &1.type) == + ["address", "uint256", "bytes32", "string"] + + assert TypedData.hash(td) == TypedData.hash(hand_built) + end + end + + describe "new/2 and new!/2 entry points" do + test "new/2 returns {:ok, td}" do + assert {:ok, %TypedData{} = td} = TypedData.new(mail_struct(), domain: @domain) + + assert TypedData.hash(td, :hex) == + "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + end + + test "new/1 on a struct defaults the domain to empty" do + assert {:ok, %TypedData{} = td} = TypedData.new(mail_struct()) + assert td.primary_type == "Mail" + end + + test "new!/2 returns the struct" do + assert %TypedData{} = TypedData.new!(mail_struct(), domain: @domain) + end + + test "a non-schema struct passed to new/2 raises a clear error" do + assert_raise ArgumentError, ~r/not an Ethers.TypedData schema/, fn -> + TypedData.new(%NotASchema{foo: 1}, []) + end + end + end +end diff --git a/test/ethers/typed_data/schema_test.exs b/test/ethers/typed_data/schema_test.exs new file mode 100644 index 00000000..f2e87918 --- /dev/null +++ b/test/ethers/typed_data/schema_test.exs @@ -0,0 +1,152 @@ +defmodule Ethers.TypedData.SchemaTest.Person do + use Ethers.TypedData.Schema + + typed_schema "Person" do + field(:name, :string) + field(:wallet, :address) + end +end + +defmodule Ethers.TypedData.SchemaTest.Mail do + use Ethers.TypedData.Schema + + alias Ethers.TypedData.SchemaTest.Person + + typed_schema "Mail" do + field(:from, Person) + field(:to, Person) + field(:contents, :string, default: "Hello") + end +end + +defmodule Ethers.TypedData.SchemaTest.Group do + use Ethers.TypedData.Schema + + alias Ethers.TypedData.SchemaTest.Person + + # No explicit type name -> derived from the module's last segment ("Group"). + typed_schema do + field(:members, {:array, Person}) + field(:group_name, :string, name: "groupName") + field(:count, :uint256, default: 0) + end +end + +defmodule Ethers.TypedData.SchemaTest do + use ExUnit.Case, async: true + + doctest Ethers.TypedData.Schema + + alias Ethers.TypedData.SchemaTest.Group + alias Ethers.TypedData.SchemaTest.Mail + alias Ethers.TypedData.SchemaTest.Person + + describe "generated struct" do + test "has the declared keys with nil defaults" do + mail = %Mail{} + + assert Map.has_key?(mail, :from) + assert Map.has_key?(mail, :to) + assert Map.has_key?(mail, :contents) + + assert mail.from == nil + assert mail.to == nil + end + + test "applies field :default values" do + assert %Mail{}.contents == "Hello" + assert %Group{}.count == 0 + assert %Group{}.members == nil + assert %Group{}.group_name == nil + end + end + + describe "__typed_data_schema__/0" do + test "returns the explicit type name and ordered field metadata" do + assert Person.__typed_data_schema__() == + {"Person", + [ + %{key: :name, name: "name", type: :string}, + %{key: :wallet, name: "wallet", type: :address} + ]} + end + + test "preserves field declaration order and raw module type terms" do + {name, fields} = Mail.__typed_data_schema__() + + assert name == "Mail" + + assert fields == [ + %{key: :from, name: "from", type: Person}, + %{key: :to, name: "to", type: Person}, + %{key: :contents, name: "contents", type: :string} + ] + + # Order is significant (EIP-712 encodeType). + assert Enum.map(fields, & &1.key) == [:from, :to, :contents] + + # :default is dropped from the introspection tuple. + refute Enum.any?(fields, &Map.has_key?(&1, :default)) + end + + test "defaults the type name to the module's last segment" do + {name, _fields} = Group.__typed_data_schema__() + + assert name == "Group" + end + + test "honors :name overrides and raw array/atom type terms" do + {_name, fields} = Group.__typed_data_schema__() + + assert fields == [ + %{key: :members, name: "members", type: {:array, Person}}, + %{key: :group_name, name: "groupName", type: :string}, + %{key: :count, name: "count", type: :uint256} + ] + end + end + + describe "compile-time guard rails" do + test "raises on duplicate field keys" do + assert_raise ArgumentError, ~r/:name is already declared/, fn -> + Code.eval_string(""" + defmodule Ethers.TypedData.SchemaTest.Dup do + use Ethers.TypedData.Schema + + typed_schema "Dup" do + field(:name, :string) + field(:name, :address) + end + end + """) + end + end + + test "raises when no field is declared" do + assert_raise ArgumentError, ~r/must declare at least one field/, fn -> + Code.eval_string(""" + defmodule Ethers.TypedData.SchemaTest.Empty do + use Ethers.TypedData.Schema + + typed_schema "Empty" do + end + end + """) + end + end + + test "raises when field key is not an atom" do + assert_raise ArgumentError, ~r/field key must be an atom/, fn -> + Code.eval_string(""" + defmodule Ethers.TypedData.SchemaTest.BadKey do + use Ethers.TypedData.Schema + + typed_schema "BadKey" do + field("name", :string) + end + end + """) + end + end + end +end diff --git a/test/ethers/typed_data_json_test.exs b/test/ethers/typed_data_json_test.exs new file mode 100644 index 00000000..0fef3e69 --- /dev/null +++ b/test/ethers/typed_data_json_test.exs @@ -0,0 +1,220 @@ +defmodule Ethers.TypedDataJsonTest do + use ExUnit.Case, async: true + + alias Ethers.TypedData + + describe "to_eip712_json/1" do + test "produces the canonical EIP-712 Mail example map" do + typed_data = + 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!" + } + ) + + expected = %{ + "types" => %{ + "EIP712Domain" => [ + %{"name" => "name", "type" => "string"}, + %{"name" => "version", "type" => "string"}, + %{"name" => "chainId", "type" => "uint256"}, + %{"name" => "verifyingContract", "type" => "address"} + ], + "Person" => [ + %{"name" => "name", "type" => "string"}, + %{"name" => "wallet", "type" => "address"} + ], + "Mail" => [ + %{"name" => "from", "type" => "Person"}, + %{"name" => "to", "type" => "Person"}, + %{"name" => "contents", "type" => "string"} + ] + }, + "primaryType" => "Mail", + "domain" => %{ + "name" => "Ether Mail", + "version" => "1", + "chainId" => "1", + "verifyingContract" => "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message" => %{ + "from" => %{ + "name" => "Cow", + "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to" => %{ + "name" => "Bob", + "wallet" => "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents" => "Hello, Bob!" + } + } + + assert TypedData.to_eip712_json(typed_data) == expected + end + + test "nested message structs become nested maps" do + json = TypedData.to_eip712_json(mail_example()) + + assert %{"message" => %{"from" => from, "to" => to}} = json + assert is_map(from) + assert is_map(to) + assert from["name"] == "Cow" + assert to["name"] == "Bob" + end + + test "chainId is serialized as a decimal string" do + json = TypedData.to_eip712_json(mail_example()) + + assert json["domain"]["chainId"] == "1" + end + + test "addresses are checksummed hex" do + json = TypedData.to_eip712_json(mail_example()) + + assert json["message"]["from"]["wallet"] == + "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + end + + test "only present domain fields appear (no salt)" do + json = TypedData.to_eip712_json(mail_example()) + + refute Map.has_key?(json["domain"], "salt") + refute Enum.any?(json["types"]["EIP712Domain"], &(&1["name"] == "salt")) + end + + test "accepts integer, decimal string and hex string integers" do + typed_data = + TypedData.new!( + types: %{ + "Amounts" => [ + %{name: "a", type: "uint256"}, + %{name: "b", type: "uint256"}, + %{name: "c", type: "int128"} + ] + }, + primary_type: "Amounts", + domain: [name: "x"], + message: %{"a" => 42, "b" => "1000000", "c" => "0xff"} + ) + + assert %{"message" => %{"a" => "42", "b" => "1000000", "c" => "255"}} = + TypedData.to_eip712_json(typed_data) + end + + test "serializes bytes and bytesN as 0x hex, accepting binaries and hex strings" do + typed_data = + TypedData.new!( + types: %{ + "Blob" => [ + %{name: "fixed", type: "bytes32"}, + %{name: "dynamic", type: "bytes"} + ] + }, + primary_type: "Blob", + domain: [name: "x"], + message: %{ + "fixed" => <<0::256>>, + "dynamic" => "0xdeadbeef" + } + ) + + json = TypedData.to_eip712_json(typed_data) + assert json["message"]["fixed"] == "0x" <> String.duplicate("00", 32) + assert json["message"]["dynamic"] == "0xdeadbeef" + end + + test "serializes arrays element by element, including nested arrays" do + typed_data = + TypedData.new!( + types: %{ + "Data" => [ + %{name: "flat", type: "uint256[]"}, + %{name: "nested", type: "uint256[][]"}, + %{name: "people", type: "Person[]"} + ], + "Person" => [ + %{name: "name", type: "string"}, + %{name: "wallet", type: "address"} + ] + }, + primary_type: "Data", + domain: [name: "x"], + message: %{ + "flat" => [1, 2, 3], + "nested" => [[1, 2], [3]], + "people" => [ + %{"name" => "Cow", "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"} + ] + } + ) + + json = TypedData.to_eip712_json(typed_data) + assert json["message"]["flat"] == ["1", "2", "3"] + assert json["message"]["nested"] == [["1", "2"], ["3"]] + + assert json["message"]["people"] == [ + %{"name" => "Cow", "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"} + ] + end + + test "output is JSON-encodable with Jason" do + assert {:ok, _json_string} = + mail_example() |> TypedData.to_eip712_json() |> Jason.encode() + end + end + + defp mail_example do + 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!" + } + ) + end +end diff --git a/test/ethers/typed_data_signing_test.exs b/test/ethers/typed_data_signing_test.exs new file mode 100644 index 00000000..4742b173 --- /dev/null +++ b/test/ethers/typed_data_signing_test.exs @@ -0,0 +1,176 @@ +defmodule Ethers.TypedDataSigningTest.NoTypedDataSigner do + @moduledoc false + # A signer that implements the required callbacks but NOT the optional `sign_typed_data/2`. + @behaviour Ethers.Signer + + @impl true + def sign_transaction(_tx, _opts), do: {:error, :not_supported} + + @impl true + def accounts(_opts), do: {:error, :not_supported} +end + +defmodule Ethers.TypedDataSigningTest do + use ExUnit.Case + + alias Ethers.TypedData + alias Ethers.TypedDataSigningTest.NoTypedDataSigner + + # @private_key -> 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1 (same key used across the suite). + @private_key "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" + @address "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" + @other_address "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + + defp mail_typed_data do + 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!" + } + ) + end + + describe "sign_typed_data/2 with the Local signer" do + test "signs, recovers the signer, and verifies (round-trip)" do + td = mail_typed_data() + + assert {:ok, signature} = + Ethers.sign_typed_data(td, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key, from: @address] + ) + + assert String.starts_with?(signature, "0x") + + # recover_signer/2 returns the checksummed signer address directly. + assert TypedData.recover_signer(td, signature) == @address + + assert TypedData.valid_signature?(td, signature, @address) + # Case-insensitive address comparison. + assert TypedData.valid_signature?(td, signature, String.downcase(@address)) + refute TypedData.valid_signature?(td, signature, @other_address) + end + + test "works without an explicit :from (key alone)" do + td = mail_typed_data() + + assert {:ok, signature} = + Ethers.sign_typed_data(td, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key] + ) + + assert TypedData.recover_signer(td, signature) == @address + end + + test "recover_signer/2 accepts a raw 65-byte binary signature" do + td = mail_typed_data() + + assert {:ok, signature} = + Ethers.sign_typed_data(td, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key] + ) + + raw = Ethers.Utils.hex_decode!(signature) + assert byte_size(raw) == 65 + assert TypedData.recover_signer(td, raw) == @address + end + end + + describe "sign_typed_data!/2" do + test "returns the raw signature hex" do + td = mail_typed_data() + + signature = + Ethers.sign_typed_data!(td, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key] + ) + + assert is_binary(signature) + assert String.starts_with?(signature, "0x") + assert TypedData.valid_signature?(td, signature, @address) + end + + test "raises on signer error" do + td = mail_typed_data() + + assert_raise Ethers.ExecutionError, fn -> + Ethers.sign_typed_data!(td, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key, from: @other_address] + ) + end + end + end + + describe "unsupported signer" do + test "returns {:error, :not_supported} when the signer does not implement the callback" do + td = mail_typed_data() + + assert {:error, :not_supported} = + Ethers.sign_typed_data(td, signer: NoTypedDataSigner) + end + end + + describe "recover_signer/2 with malformed signatures" do + setup do + %{td: mail_typed_data()} + end + + test "returns {:error, :invalid_signature} for a wrong-length binary", %{td: td} do + assert {:error, :invalid_signature} = TypedData.recover_signer(td, <<1, 2, 3>>) + end + + test "returns {:error, :invalid_signature} for a wrong-length hex string", %{td: td} do + assert {:error, :invalid_signature} = TypedData.recover_signer(td, "0xdeadbeef") + end + + test "returns {:error, :invalid_signature} for non-hex input", %{td: td} do + assert {:error, :invalid_signature} = TypedData.recover_signer(td, "0xnothexatall") + end + + test "valid_signature?/3 returns false (does not raise) on a malformed signature", %{td: td} do + refute TypedData.valid_signature?(td, "0xdeadbeef", @address) + end + end + + describe "recover_signer/2 v normalization" do + test "accepts v as raw parity (0/1) as well as 27/28" do + td = mail_typed_data() + + {:ok, signature} = + Ethers.sign_typed_data(td, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key] + ) + + <> = Ethers.Utils.hex_decode!(signature) + assert v in [27, 28] + + # The same signature with v expressed as raw parity (0 or 1) must recover the same address. + raw_parity = v - 27 + assert TypedData.recover_signer(td, <>) == @address + end + end +end diff --git a/test/ethers/typed_data_test.exs b/test/ethers/typed_data_test.exs new file mode 100644 index 00000000..7f77b477 --- /dev/null +++ b/test/ethers/typed_data_test.exs @@ -0,0 +1,264 @@ +defmodule Ethers.TypedDataTest do + use ExUnit.Case + + doctest Ethers.TypedData + doctest Ethers.TypedData.Domain + doctest Ethers.TypedData.Field + + alias Ethers.TypedData + alias Ethers.TypedData.Domain + alias Ethers.TypedData.Field + + @mail_types %{ + "Person" => [ + %{name: "name", type: "string"}, + %{name: "wallet", type: "address"} + ], + "Mail" => [ + %{name: "from", type: "Person"}, + %{name: "to", type: "Person"}, + %{name: "contents", type: "string"} + ] + } + + @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!" + } + + describe "new/1" do + test "builds the Mail/Person example and normalizes types to Field structs" do + assert {:ok, typed_data} = + TypedData.new( + types: @mail_types, + primary_type: "Mail", + domain: @domain, + message: @message + ) + + assert %TypedData{primary_type: "Mail"} = typed_data + assert %Domain{name: "Ether Mail", chain_id: 1} = typed_data.domain + + assert typed_data.types["Person"] == [ + %Field{name: "name", type: "string"}, + %Field{name: "wallet", type: "address"} + ] + + assert typed_data.types["Mail"] == [ + %Field{name: "from", type: "Person"}, + %Field{name: "to", type: "Person"}, + %Field{name: "contents", type: "string"} + ] + end + + test "accepts fields as maps with string keys and as Field structs" do + types = %{ + "Person" => [ + %{"name" => "name", "type" => "string"}, + %Field{name: "wallet", type: "address"} + ] + } + + assert {:ok, typed_data} = + TypedData.new( + types: types, + primary_type: "Person", + domain: %{}, + message: %{"name" => "Cow"} + ) + + assert typed_data.types["Person"] == [ + %Field{name: "name", type: "string"}, + %Field{name: "wallet", type: "address"} + ] + end + + test "normalizes message atom keys to string keys" do + assert {:ok, typed_data} = + TypedData.new( + types: @mail_types, + primary_type: "Mail", + domain: @domain, + message: %{ + from: %{name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, + contents: "Hello, Bob!" + } + ) + + assert typed_data.message == %{ + "from" => %{ + "name" => "Cow", + "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "contents" => "Hello, Bob!" + } + end + + test "string message keys are preserved" do + assert {:ok, typed_data} = + TypedData.new( + types: @mail_types, + primary_type: "Mail", + domain: @domain, + message: @message + ) + + assert typed_data.message == @message + end + + test "accepts a Domain struct for :domain" do + domain = Domain.new(@domain) + + assert {:ok, typed_data} = + TypedData.new( + types: @mail_types, + primary_type: "Mail", + domain: domain, + message: @message + ) + + assert typed_data.domain == domain + end + + test "returns error when primary_type is not defined in types" do + assert {:error, {:unknown_primary_type, "Ghost"}} = + TypedData.new( + types: @mail_types, + primary_type: "Ghost", + domain: @domain, + message: @message + ) + end + + test "returns error when a member references an undefined struct type" do + types = %{ + "Mail" => [ + %{name: "from", type: "Person"}, + %{name: "contents", type: "string"} + ] + } + + assert {:error, {:undefined_type, "Person"}} = + TypedData.new( + types: types, + primary_type: "Mail", + domain: @domain, + message: @message + ) + end + + test "allows array member types referencing defined structs" do + types = %{ + "Person" => [%{name: "name", type: "string"}], + "Group" => [%{name: "members", type: "Person[]"}] + } + + assert {:ok, _typed_data} = + TypedData.new( + types: types, + primary_type: "Group", + domain: %{}, + message: %{"members" => []} + ) + end + end + + describe "new!/1" do + test "returns the struct on success" do + assert %TypedData{primary_type: "Mail"} = + TypedData.new!( + types: @mail_types, + primary_type: "Mail", + domain: @domain, + message: @message + ) + end + + test "raises on unknown primary type" do + assert_raise ArgumentError, ~r/unknown_primary_type/, fn -> + TypedData.new!( + types: @mail_types, + primary_type: "Ghost", + domain: @domain, + message: @message + ) + end + end + + test "raises on undefined referenced type" do + types = %{"Mail" => [%{name: "from", type: "Person"}]} + + assert_raise ArgumentError, ~r/undefined_type/, fn -> + TypedData.new!( + types: types, + primary_type: "Mail", + domain: @domain, + message: %{} + ) + end + end + end + + describe "Domain.new/1" do + test "builds from a keyword list" do + assert %Domain{ + name: "Ether Mail", + version: "1", + chain_id: 1, + verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + salt: nil + } = Domain.new(@domain) + end + + test "builds from a map with atom and string keys" do + assert %Domain{name: "A", chain_id: 5} = Domain.new(%{name: "A", chain_id: 5}) + assert %Domain{name: "A", chain_id: 5} = Domain.new(%{"name" => "A", "chain_id" => 5}) + end + + test "returns an existing Domain struct unchanged" do + domain = Domain.new(@domain) + assert Domain.new(domain) == domain + end + end + + describe "Domain.present_fields/1" do + test "returns only non-nil fields in canonical order with solidity types" do + domain = + Domain.new( + name: "Ether Mail", + version: "1", + chain_id: 1, + verifying_contract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + ) + + assert Domain.present_fields(domain) == [ + %Field{name: "name", type: "string"}, + %Field{name: "version", type: "string"}, + %Field{name: "chainId", type: "uint256"}, + %Field{name: "verifyingContract", type: "address"} + ] + end + + test "includes salt as bytes32 and preserves canonical ordering" do + domain = Domain.new(chain_id: 1, salt: <<0::256>>, name: "A") + + assert Domain.present_fields(domain) == [ + %Field{name: "name", type: "string"}, + %Field{name: "chainId", type: "uint256"}, + %Field{name: "salt", type: "bytes32"} + ] + end + + test "returns an empty list for an empty domain" do + assert Domain.present_fields(Domain.new(%{})) == [] + end + end +end diff --git a/test/support/eip712_schemas.ex b/test/support/eip712_schemas.ex new file mode 100644 index 00000000..9e862841 --- /dev/null +++ b/test/support/eip712_schemas.ex @@ -0,0 +1,24 @@ +defmodule Ethers.Support.EIP712.Person do + @moduledoc false + # Example EIP-712 schema used by the `Ethers.TypedData.Schema` moduledoc doctest. + use Ethers.TypedData.Schema + + typed_schema "Person" do + field(:name, :string) + field(:wallet, :address) + end +end + +defmodule Ethers.Support.EIP712.Mail do + @moduledoc false + # Example EIP-712 schema used by the `Ethers.TypedData.Schema` moduledoc doctest. + use Ethers.TypedData.Schema + + alias Ethers.Support.EIP712.Person + + typed_schema "Mail" do + field(:from, Person) + field(:to, Person) + field(:contents, :string) + end +end