diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b46e74..a648ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/lib/ethers.ex b/lib/ethers.ex index eb882e9..ad3071a 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -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. diff --git a/lib/ethers/personal_message.ex b/lib/ethers/personal_message.ex new file mode 100644 index 0000000..6530263 --- /dev/null +++ b/lib/ethers/personal_message.ex @@ -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. + """ + + 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, <>} <- + 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. + + ## 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 diff --git a/lib/ethers/signer.ex b/lib/ethers/signer.ex index c19af2c..7000142 100644 --- a/lib/ethers/signer.ex +++ b/lib/ethers/signer.ex @@ -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 @@ -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 diff --git a/lib/ethers/signer/json_rpc.ex b/lib/ethers/signer/json_rpc.ex index e1a99ca..c41bcc0 100644 --- a/lib/ethers/signer/json_rpc.ex +++ b/lib/ethers/signer/json_rpc.ex @@ -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 @@ -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) diff --git a/lib/ethers/signer/local.ex b/lib/ethers/signer/local.ex index d81eac1..13c482b 100644 --- a/lib/ethers/signer/local.ex +++ b/lib/ethers/signer/local.ex @@ -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 <> <>)} + end + end + @impl true def accounts(opts) do with {:ok, private_key} <- private_key(opts), @@ -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 diff --git a/test/ethers/personal_message_test.exs b/test/ethers/personal_message_test.exs new file mode 100644 index 0000000..94efe25 --- /dev/null +++ b/test/ethers/personal_message_test.exs @@ -0,0 +1,295 @@ +defmodule Ethers.PersonalMessageTest.NoMessageSigner do + @moduledoc false + # A signer that implements the required callbacks but NOT the optional `personal_sign/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.PersonalMessageTest.BuggySigner do + @moduledoc false + # A signer whose `personal_sign/2` raises an UndefinedFunctionError for a *different* + # module — it must be re-raised, not translated to {:error, :not_supported}. + @behaviour Ethers.Signer + + @impl true + def sign_transaction(_tx, _opts), do: {:error, :not_supported} + + @impl true + def accounts(_opts), do: {:error, :not_supported} + + @impl true + def personal_sign(_message, _opts) do + module = Module.concat(__MODULE__, NonExistent) + module.call() + end +end + +defmodule Ethers.PersonalMessageTest do + use ExUnit.Case, async: true + + alias Ethers.PersonalMessage + alias Ethers.PersonalMessageTest.NoMessageSigner + alias Ethers.Utils + + doctest Ethers.PersonalMessage + + # Well-known anvil/hardhat development keys. + @private_key "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + @address "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + @other_private_key "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + @other_address "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + + # Ground-truth vectors generated with foundry: + # `cast hash-message ` and `cast wallet sign --private-key ` + @vectors [ + %{ + message: "Hello world", + key: @private_key, + address: @address, + hash: "0x8144a6fa26be252b86456491fbcd43c1de7e022241845ffea1c3df066f7cfede", + signature: + "0x15a3fe3974ebe469b00e67ad67bb3860ad3fc3d739287cdbc4ba558ce7130bee205e5e38d6ef156f1ff6a4df17bfa72a1e61c429f92613f3efbc58394d00c9891b" + }, + %{ + message: "", + key: @private_key, + address: @address, + hash: "0x5f35dce98ba4fba25530a026ed80b2cecdaa31091ba4958b99b52ea1d068adad", + signature: + "0xc1977b761f1dd36c29795783460d241885c8e7f9d962dbe7bba2753fd94e89b444a1cd9ed855dd09afa3b73f7c2bd097ec9abc2d2775d737505a02d3f0cafa591b" + }, + %{ + message: "héllo wörld ⚡", + key: @other_private_key, + address: @other_address, + hash: "0xd5a255506d3e767277bde6078f42d4ef18ad3212630b5809d97df20548863b93", + signature: + "0xe02ed32b4483d5a6994deda655065e91f3950445b1d953f650b2729833934cf069540544671d86db7da92056d965924cee2b5542a0e534ec08db789b8472a0021b" + }, + %{ + # `cast wallet sign 0xdeadbeef` hex-decodes and signs the *raw bytes*; in Elixir raw + # bytes are just a binary, so the equivalent input is <<0xDE, 0xAD, 0xBE, 0xEF>>. + # Hash cross-checked with `printf '\x19Ethereum Signed Message:\n4\xde\xad\xbe\xef' | cast keccak`. + message: <<0xDE, 0xAD, 0xBE, 0xEF>>, + key: @other_private_key, + address: @other_address, + hash: "0xd1c7f1a06a4f9a535077e50ad23244ce2c6ae443fcd412965226f3df5d28eaaa", + signature: + "0xa443ea4651a6a91569bb9924b5eba4c2e267a0c922f5dec2036cdc460b004fbc5f4082976d9815d70815f4661d27e9ab4ab5b60eb417bcfc23b3d2a4dab9b5891c" + } + ] + + describe "hash/1" do + test "matches the reference EIP-191 (version 0x45) hashes" do + for vector <- @vectors do + assert PersonalMessage.hash(vector.message) == Utils.hex_decode!(vector.hash), + "hash mismatch for message #{inspect(vector.message)}" + end + end + + test "returns a 32-byte binary" do + assert <<_::binary-size(32)>> = PersonalMessage.hash("Hello world") + end + + test "treats a 0x-prefixed string as literal text, not hex bytes" do + # "0xdeadbeef" as *text* is 10 bytes and must NOT hash like the 4 raw bytes. + refute PersonalMessage.hash("0xdeadbeef") == + PersonalMessage.hash(<<0xDE, 0xAD, 0xBE, 0xEF>>) + + # Reference for the literal-text semantics: `cast hash-message 0xdeadbeef` (which, + # unlike `cast wallet sign`, treats its argument as a plain string). + assert PersonalMessage.hash("0xdeadbeef") == + Utils.hex_decode!( + "0xefedd0a9a0294228c3977d7fbb68c7d40279f8b408cf3e24ef1823b179709e58" + ) + end + end + + describe "recover/2" do + test "recovers the checksummed signer address from the reference vectors" do + for vector <- @vectors do + assert {:ok, vector.address} == PersonalMessage.recover(vector.message, vector.signature), + "recover mismatch for message #{inspect(vector.message)}" + end + end + + test "accepts a raw 65-byte binary signature" do + [vector | _] = @vectors + raw = Utils.hex_decode!(vector.signature) + assert byte_size(raw) == 65 + assert {:ok, vector.address} == PersonalMessage.recover(vector.message, raw) + end + + test "accepts v as raw parity (0/1) as well as 27/28" do + [vector | _] = @vectors + <> = Utils.hex_decode!(vector.signature) + assert v in [27, 28] + + assert {:ok, vector.address} == + PersonalMessage.recover(vector.message, <>) + end + + test "returns {:error, :invalid_signature} for a wrong-length binary" do + assert {:error, :invalid_signature} = PersonalMessage.recover("Hello world", <<1, 2, 3>>) + end + + test "returns {:error, :invalid_signature} for a wrong-length hex string" do + assert {:error, :invalid_signature} = PersonalMessage.recover("Hello world", "0xdeadbeef") + end + + test "returns {:error, :invalid_signature} for non-hex input" do + assert {:error, :invalid_signature} = + PersonalMessage.recover("Hello world", "0xnothexatall") + end + + test "returns {:error, :invalid_signature} for an out-of-range v" do + [vector | _] = @vectors + <> = Utils.hex_decode!(vector.signature) + + assert {:error, :invalid_signature} = + PersonalMessage.recover(vector.message, <>) + end + end + + describe "recover!/2" do + test "returns the address directly" do + [vector | _] = @vectors + assert PersonalMessage.recover!(vector.message, vector.signature) == vector.address + end + + test "raises on a malformed signature" do + assert_raise Ethers.ExecutionError, fn -> + PersonalMessage.recover!("Hello world", "0xdeadbeef") + end + end + end + + describe "verify/3" do + test "returns true for the reference vectors" do + for vector <- @vectors do + assert PersonalMessage.verify(vector.message, vector.signature, vector.address) + end + end + + test "compares addresses case-insensitively" do + [vector | _] = @vectors + "0x" <> address_hex = vector.address + + assert PersonalMessage.verify( + vector.message, + vector.signature, + String.downcase(vector.address) + ) + + assert PersonalMessage.verify( + vector.message, + vector.signature, + "0x" <> String.upcase(address_hex) + ) + end + + test "returns false for a different address" do + [vector | _] = @vectors + refute PersonalMessage.verify(vector.message, vector.signature, @other_address) + end + + test "returns false for a different message" do + [vector | _] = @vectors + refute PersonalMessage.verify("Goodbye world", vector.signature, vector.address) + end + + test "returns false (does not raise) on a malformed signature" do + refute PersonalMessage.verify("Hello world", "0xdeadbeef", @address) + end + end + + describe "Ethers.personal_sign/2 with the Local signer" do + test "produces the exact reference signature for each vector" do + for vector <- @vectors do + assert {:ok, vector.signature} == + Ethers.personal_sign(vector.message, + signer: Ethers.Signer.Local, + signer_opts: [private_key: vector.key] + ), + "signature mismatch for message #{inspect(vector.message)}" + end + end + + test "signs, recovers, and verifies (round-trip)" do + message = "sign in to example.com" + + assert {:ok, signature} = + Ethers.personal_sign(message, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key, from: @address] + ) + + assert {:ok, @address} = PersonalMessage.recover(message, signature) + assert PersonalMessage.verify(message, signature, @address) + refute PersonalMessage.verify(message, signature, @other_address) + end + + test "signs a 0x-prefixed string as literal text (round-trip)" do + message = "0xdeadbeef" + + assert {:ok, signature} = + Ethers.personal_sign(message, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key] + ) + + assert {:ok, @address} = PersonalMessage.recover(message, signature) + # And it is NOT the signature over the raw bytes 0xde 0xad 0xbe 0xef. + refute PersonalMessage.verify(<<0xDE, 0xAD, 0xBE, 0xEF>>, signature, @address) + end + + test "returns :wrong_key when :from does not match the private key" do + assert {:error, :wrong_key} = + Ethers.personal_sign("Hello world", + signer: Ethers.Signer.Local, + signer_opts: [private_key: @private_key, from: @other_address] + ) + end + end + + describe "Ethers.personal_sign!/2" do + test "returns the raw signature hex" do + [vector | _] = @vectors + + signature = + Ethers.personal_sign!(vector.message, + signer: Ethers.Signer.Local, + signer_opts: [private_key: vector.key] + ) + + assert signature == vector.signature + end + + test "raises on signer error" do + assert_raise Ethers.ExecutionError, fn -> + Ethers.personal_sign!("Hello world", + 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 + assert {:error, :not_supported} = + Ethers.personal_sign("Hello world", signer: NoMessageSigner) + end + + test "re-raises UndefinedFunctionError coming from within the signer" do + assert_raise UndefinedFunctionError, ~r/NonExistent/, fn -> + Ethers.personal_sign("Hello world", signer: Ethers.PersonalMessageTest.BuggySigner) + end + end + end +end diff --git a/test/ethers/signer/json_rpc_test.exs b/test/ethers/signer/json_rpc_test.exs index fe13d6b..6dd728a 100644 --- a/test/ethers/signer/json_rpc_test.exs +++ b/test/ethers/signer/json_rpc_test.exs @@ -117,6 +117,30 @@ defmodule Ethers.Signer.JsonRPCTest do end end + describe "personal_sign/2" do + test "signs a message for the default account (verified via recovery)" do + from = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + message = "sign in to example.com" + + assert {:ok, signature} = Signer.JsonRPC.personal_sign(message, from: from) + + assert <<"0x", hex::binary-size(130)>> = signature + assert String.match?(hex, ~r/^[0-9a-fA-F]{130}$/) + assert Ethers.PersonalMessage.verify(message, signature, from) + end + + test "fails signing with an unknown from address" do + assert {:error, _reason} = + Signer.JsonRPC.personal_sign("Hello world", + from: "0xbba94ef8bd5ffee41947b4585a84bda5a3d3da6e" + ) + end + + test "returns an error tuple (not a raise) when :from is missing" do + assert {:error, :missing_from_address} = Signer.JsonRPC.personal_sign("Hello world", []) + 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 27f11cc..a989d5f 100644 --- a/test/ethers/signer/local_test.exs +++ b/test/ethers/signer/local_test.exs @@ -105,6 +105,52 @@ defmodule Ethers.Signer.LocalTest do end end + describe "personal_sign/2" do + # Expected signature generated independently with foundry: + # cast wallet sign --private-key "Hello world" + @anvil_private_key "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + @anvil_address "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + @hello_world_signature "0x15a3fe3974ebe469b00e67ad67bb3860ad3fc3d739287cdbc4ba558ce7130bee205e5e38d6ef156f1ff6a4df17bfa72a1e61c429f92613f3efbc58394d00c9891b" + + test "produces the exact signature for a fixed key and message" do + assert {:ok, @hello_world_signature} == + Signer.Local.personal_sign("Hello world", private_key: @anvil_private_key) + end + + test "accepts a matching :from address" do + assert {:ok, @hello_world_signature} == + Signer.Local.personal_sign("Hello world", + private_key: @anvil_private_key, + from: @anvil_address + ) + end + + test "returns :wrong_key when :from does not match the private key" do + assert {:error, :wrong_key} = + Signer.Local.personal_sign("Hello world", + private_key: @anvil_private_key, + from: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" + ) + end + + test "returns :no_private_key when no key is given" do + assert {:error, :no_private_key} = Signer.Local.personal_sign("Hello world", []) + end + + test "matches anvil's personal_sign byte-for-byte" do + message = "Hello from the JsonRPC signer" + + assert {:ok, local_signature} = + Signer.Local.personal_sign(message, private_key: @anvil_private_key) + + assert {:ok, anvil_signature} = + Signer.JsonRPC.personal_sign(message, from: String.downcase(@anvil_address)) + + # 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 =