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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Enhancements

- Add [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal message support: hash, recover
and verify messages with `Ethers.PersonalMessage`, and sign them via `Ethers.personal_sign/2`
with the `Ethers.Signer.Local` and `Ethers.Signer.JsonRPC` signers through the new optional
`personal_sign/2` signer callback (named after the RPC method it mirrors)
- 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`
Expand Down
67 changes: 67 additions & 0 deletions lib/ethers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,73 @@ defmodule Ethers do
end
end

@doc """
Signs an [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal message (the
`personal_sign` scheme) and returns the signature as a `0x`-prefixed hex string
(65 bytes: `r ‖ s ‖ v`).

The message is treated as raw bytes, exactly as given — a `"0x..."`-prefixed string is
signed as literal text, not hex-decoded. See `Ethers.PersonalMessage` for the hashing
scheme and the pure recover/verify counterparts.

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
Ethers.personal_sign("Hello world",
signer: Ethers.Signer.Local,
signer_opts: [private_key: "0x...", from: "0x..."]
)
#=> {:ok, "0x..."}
```
"""
@spec personal_sign(binary(), Keyword.t()) :: {:ok, binary()} | {:error, term()}
def personal_sign(message, opts \\ []) when is_binary(message) do
{opts, _} = Keyword.split(opts, @option_keys)

default_signer = default_signer() || Ethers.Signer.JsonRPC

with {:ok, signer} <- get_signer(opts, default_signer) do
do_personal_sign(signer, message, build_signer_opts(%{}, opts))
end
end

# `personal_sign/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_personal_sign(signer, message, signer_opts) do
signer.personal_sign(message, signer_opts)
rescue
error in UndefinedFunctionError ->
case error do
%UndefinedFunctionError{module: ^signer, function: :personal_sign, arity: 2} ->
{:error, :not_supported}

_ ->
reraise error, __STACKTRACE__
end
end

@doc """
Same as `Ethers.personal_sign/2` but raises on error.
"""
@spec personal_sign!(binary(), Keyword.t()) :: binary() | no_return()
def personal_sign!(message, opts \\ []) do
case personal_sign(message, 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.

Expand Down
138 changes: 138 additions & 0 deletions lib/ethers/personal_message.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
defmodule Ethers.PersonalMessage do
@moduledoc """
[EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal message utilities
(the `personal_sign` scheme, version byte `0x45`).

Personal messages are what wallets sign when an app requests `personal_sign` — wallet
login flows, off-chain agreements, API authentication and the like. The signed payload
is not the raw message but its EIP-191 hash:

keccak256("\\x19Ethereum Signed Message:\\n" <> Integer.to_string(byte_size(message)) <> message)

This module provides the pure primitives: hashing (`hash/1`), signer recovery
(`recover/2`) and signature verification (`verify/3`). To produce a signature use
`Ethers.personal_sign/2`, which routes through the configured `Ethers.Signer`.

## Message encoding

The message is always treated as raw bytes, exactly as given. In particular a
`"0x..."`-prefixed string is signed as the literal text, **not** hex-decoded. If you
want to sign pre-encoded bytes, decode them yourself first (e.g. with
`Ethers.Utils.hex_decode!/1`).

## Verification scope

Recovery and verification here are `ecrecover`-based and therefore work for
externally-owned accounts (EOAs) only. Signatures from smart-contract wallets
(ERC-1271/ERC-6492) cannot be verified this way and need the RPC-backed
`Ethers.Signature` module.
Comment thread
alisinabh marked this conversation as resolved.
"""

alias Ethers.ExecutionError
alias Ethers.Utils

@prefix "\x19Ethereum Signed Message:\n"

@doc """
Calculates the EIP-191 (version `0x45`) hash of a personal message.

The message is treated as raw bytes. Returns the 32-byte digest.

## Examples

iex> Ethers.PersonalMessage.hash("Hello world") |> Ethers.Utils.hex_encode()
"0x8144a6fa26be252b86456491fbcd43c1de7e022241845ffea1c3df066f7cfede"
"""
@spec hash(binary()) :: <<_::256>>
def hash(message) when is_binary(message) do
Ethers.keccak_module().hash_256(@prefix <> Integer.to_string(byte_size(message)) <> message)
end

@doc """
Recovers the address which signed a personal message.

`signature` may be a `0x`-prefixed hex string or a raw 65-byte binary (`r ‖ s ‖ v`).
Both the message-signature convention (`v ∈ {27, 28}`) and raw parity (`v ∈ {0, 1}`)
are accepted.

## Returns

- `{:ok, address}` with the checksummed signer address on success.
- `{:error, reason}` if the signature is malformed or recovery fails.

## Examples

iex> signature =
...> "0x15a3fe3974ebe469b00e67ad67bb3860ad3fc3d739287cdbc4ba558ce7130bee" <>
...> "205e5e38d6ef156f1ff6a4df17bfa72a1e61c429f92613f3efbc58394d00c9891b"
iex> Ethers.PersonalMessage.recover("Hello world", signature)
{:ok, "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}
"""
@spec recover(binary(), binary()) :: {:ok, Ethers.Types.t_address()} | {:error, term()}
def recover(message, signature) when is_binary(message) and is_binary(signature) do
with {:ok, <<r::binary-size(32), s::binary-size(32), v::integer>>} <-
normalize_signature(signature),
{:ok, recovery_id} <- normalize_recovery_id(v),
{:ok, public_key} <-
Ethers.secp256k1_module().recover(hash(message), r, s, recovery_id) do
{:ok, Utils.public_key_to_address(public_key)}
end
end

@doc """
Same as `recover/2` but raises on error.
"""
@spec recover!(binary(), binary()) :: Ethers.Types.t_address() | no_return()
def recover!(message, signature) do
case recover(message, signature) do
{:ok, address} -> address
{:error, reason} -> raise ExecutionError, reason
end
end

@doc """
Checks whether `signature` over `message` was produced by `expected_address`.

Recovers the signer via `recover/2` and compares it to `expected_address`. The
comparison is done on the decoded 20-byte addresses, so checksum/case differences are
ignored. Returns `false` (does not raise) when the signature is malformed.

EOA-only — for smart-contract wallets use the RPC-backed `Ethers.Signature` module.
Comment thread
alisinabh marked this conversation as resolved.

## Examples

iex> signature =
...> "0x15a3fe3974ebe469b00e67ad67bb3860ad3fc3d739287cdbc4ba558ce7130bee" <>
...> "205e5e38d6ef156f1ff6a4df17bfa72a1e61c429f92613f3efbc58394d00c9891b"
iex> Ethers.PersonalMessage.verify("Hello world", signature, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")
true
"""
@spec verify(binary(), binary(), Ethers.Types.t_address()) :: boolean()
def verify(message, signature, expected_address) do
case recover(message, signature) do
{:ok, recovered} ->
Utils.decode_address!(recovered) == Utils.decode_address!(expected_address)

{:error, _reason} ->
false
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}
end
25 changes: 22 additions & 3 deletions lib/ethers/signer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ defmodule Ethers.Signer do
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.
[EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data (see `Ethers.TypedData`)
and the optional `c:personal_sign/2` callback to support signing
[EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal messages (see
`Ethers.PersonalMessage`). Signers that do not implement them will simply not support those
signing schemes.

## Globally Default Signer
If desired, a signer can be configured to be used for all operations in Ethers using elixir
Expand Down Expand Up @@ -77,5 +80,21 @@ defmodule Ethers.Signer do
@callback sign_typed_data(typed_data :: Ethers.TypedData.t(), opts :: Keyword.t()) ::
{:ok, binary()} | {:error, reason :: term()}

@optional_callbacks sign_typed_data: 2
@doc """
Signs an [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal message and returns the
signature.

This is an optional callback. Signers that do not implement it do not support personal
message signing.

## Parameters
- message: The message to sign, as raw bytes. (See `Ethers.PersonalMessage`)
- 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 personal_sign(message :: binary(), opts :: Keyword.t()) ::
{:ok, binary()} | {:error, reason :: term()}

@optional_callbacks sign_typed_data: 2, personal_sign: 2
end
16 changes: 15 additions & 1 deletion lib/ethers/signer/json_rpc.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
defmodule Ethers.Signer.JsonRPC do
@moduledoc """
Signer capable of signing transactions with a JSON RPC server
capable of `eth_signTransaction`, `eth_signTypedData_v4` and `eth_accounts` RPC functions.
capable of `eth_signTransaction`, `eth_signTypedData_v4`, `personal_sign` and `eth_accounts`
RPC functions.

## Signer Options

Expand Down Expand Up @@ -50,6 +51,19 @@ defmodule Ethers.Signer.JsonRPC do
end
end

@impl true
def personal_sign(message, opts) do
case Keyword.get(opts, :from) do
nil ->
{:error, :missing_from_address}

from ->
{rpc_module, opts} = Keyword.pop(opts, :rpc_module, Ethereumex.HttpClient)

rpc_module.request("personal_sign", [Ethers.Utils.hex_encode(message), from], opts)
end
end

@impl true
def accounts(opts) do
{rpc_module, opts} = Keyword.pop(opts, :rpc_module, Ethereumex.HttpClient)
Expand Down
14 changes: 14 additions & 0 deletions lib/ethers/signer/local.ex
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ defmodule Ethers.Signer.Local do
end
end

@impl true
def personal_sign(message, opts) do
digest = Ethers.PersonalMessage.hash(message)

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 <> <<recovery_id + 27>>)}
end
end

@impl true
def accounts(opts) do
with {:ok, private_key} <- private_key(opts),
Expand All @@ -68,6 +79,9 @@ defmodule Ethers.Signer.Local do
@impl true
def sign_typed_data(_typed_data, _opts), do: {:error, :secp256k1_module_not_loaded}

@impl true
def personal_sign(_message, _opts), do: {:error, :secp256k1_module_not_loaded}

@impl true
def accounts(_opts), do: {:error, :secp256k1_module_not_loaded}
end
Expand Down
Loading
Loading