From 5f5744f7f62016b05baea3ed01bf8ebeaff628e0 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sat, 18 Jul 2026 23:31:39 -0400 Subject: [PATCH 1/4] Add universal signature verification (ERC-1271 + ERC-6492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Ethers.Signature with verify_hash/4, verify_message/4 and verify_typed_data/4, verifying any signature kind against an address: - Plain EOA ECDSA signatures verify locally via ecrecover with no RPC round-trip (fast path). - ERC-1271 smart-contract wallet signatures and ERC-6492-wrapped signatures from counterfactual (not-yet-deployed) wallets verify with a single deployless eth_call executing the ERC-6492 UniversalSigValidator reference contract (bytecode vendored from viem / Ambire signature-validator, shipped as a constant in Ethers.Contracts.UniversalSigValidator — nothing is deployed). Invalid signatures return {:ok, false}; {:error, reason} is reserved for malformed input and RPC transport failures, so there are deliberately no bang variants. Tested against anvil with a minimal ERC-1271 wallet fixture and a CREATE2 factory fixture covering deployed-wallet and counterfactual (ERC-6492) verification, plus EOA fast-path tests proving no RPC usage. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + .../contracts/universal_sig_validator.ex | 51 +++ lib/ethers/signature.ex | 195 ++++++++ test/ethers/signature_test.exs | 423 ++++++++++++++++++ test/support/contracts/create2_factory.sol | 16 + test/support/contracts/erc1271_wallet.sol | 44 ++ 6 files changed, 734 insertions(+) create mode 100644 lib/ethers/contracts/universal_sig_validator.ex create mode 100644 lib/ethers/signature.ex create mode 100644 test/ethers/signature_test.exs create mode 100644 test/support/contracts/create2_factory.sol create mode 100644 test/support/contracts/erc1271_wallet.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index a648ff8..f6cc7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ 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 universal signature verification with `Ethers.Signature`: verify any signature — + EOA (`ecrecover`), smart-contract wallets ([ERC-1271](https://eips.ethereum.org/EIPS/eip-1271)) + and counterfactual, not-yet-deployed wallets ([ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) — + against a digest, an EIP-191 personal message or EIP-712 typed data with + `Ethers.Signature.verify_hash/4`, `verify_message/4` and `verify_typed_data/4` - 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/contracts/universal_sig_validator.ex b/lib/ethers/contracts/universal_sig_validator.ex new file mode 100644 index 0000000..2fa3cd8 --- /dev/null +++ b/lib/ethers/contracts/universal_sig_validator.ex @@ -0,0 +1,51 @@ +defmodule Ethers.Contracts.UniversalSigValidator do + @moduledoc """ + [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492) universal signature validator + (the `ValidateSigOffchain` helper contract). + + This contract is never deployed. Its creation code is executed *deploylessly* via + `eth_call` (an `eth_call` with no `to` address runs the given data as init code and + returns whatever the constructor returns). The constructor + `(address _signer, bytes32 _hash, bytes _signature)` validates the signature — + handling ERC-6492 wrapped signatures for counterfactual (not-yet-deployed) wallets, + ERC-1271 smart-contract wallets, and plain `ecrecover` EOA signatures — and returns + a single byte: `0x01` for valid, `0x00` for invalid. + + Used by `Ethers.Signature` — you normally don't need to use this module directly. + + ## Bytecode provenance + + The creation bytecode is the `erc6492SignatureValidatorByteCode` constant from + [viem](https://github.com/wevm/viem/blob/main/src/constants/contracts.ts), which is + the compiled `ValidateSigOffchain` helper from the ERC-6492 reference implementation + (Ambire's [signature-validator](https://github.com/AmbireTech/signature-validator)). + """ + + @creation_code Ethers.Utils.hex_decode!( + "0x608060405234801561001057600080fd5b5060405161069438038061069483398101604081905261002f9161051e565b600061003c848484610048565b9050806000526001601ff35b60007f64926492649264926492649264926492649264926492649264926492649264926100748361040c565b036101e7576000606080848060200190518101906100929190610577565b60405192955090935091506000906001600160a01b038516906100b69085906105dd565b6000604051808303816000865af19150503d80600081146100f3576040519150601f19603f3d011682016040523d82523d6000602084013e6100f8565b606091505b50509050876001600160a01b03163b60000361016057806101605760405162461bcd60e51b815260206004820152601e60248201527f5369676e617475726556616c696461746f723a206465706c6f796d656e74000060448201526064015b60405180910390fd5b604051630b135d3f60e11b808252906001600160a01b038a1690631626ba7e90610190908b9087906004016105f9565b602060405180830381865afa1580156101ad573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906101d19190610633565b6001600160e01b03191614945050505050610405565b6001600160a01b0384163b1561027a57604051630b135d3f60e11b808252906001600160a01b03861690631626ba7e9061022790879087906004016105f9565b602060405180830381865afa158015610244573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102689190610633565b6001600160e01b031916149050610405565b81516041146102df5760405162461bcd60e51b815260206004820152603a602482015260008051602061067483398151915260448201527f3a20696e76616c6964207369676e6174757265206c656e6774680000000000006064820152608401610157565b6102e7610425565b5060208201516040808401518451859392600091859190811061030c5761030c61065d565b016020015160f81c9050601b811480159061032b57508060ff16601c14155b1561038c5760405162461bcd60e51b815260206004820152603b602482015260008051602061067483398151915260448201527f3a20696e76616c6964207369676e617475726520762076616c756500000000006064820152608401610157565b60408051600081526020810180835289905260ff83169181019190915260608101849052608081018390526001600160a01b0389169060019060a0016020604051602081039080840390855afa1580156103ea573d6000803e3d6000fd5b505050602060405103516001600160a01b0316149450505050505b9392505050565b600060208251101561041d57600080fd5b508051015190565b60405180606001604052806003906020820280368337509192915050565b6001600160a01b038116811461045857600080fd5b50565b634e487b7160e01b600052604160045260246000fd5b60005b8381101561048c578181015183820152602001610474565b50506000910152565b600082601f8301126104a657600080fd5b81516001600160401b038111156104bf576104bf61045b565b604051601f8201601f19908116603f011681016001600160401b03811182821017156104ed576104ed61045b565b60405281815283820160200185101561050557600080fd5b610516826020830160208701610471565b949350505050565b60008060006060848603121561053357600080fd5b835161053e81610443565b6020850151604086015191945092506001600160401b0381111561056157600080fd5b61056d86828701610495565b9150509250925092565b60008060006060848603121561058c57600080fd5b835161059781610443565b60208501519093506001600160401b038111156105b357600080fd5b6105bf86828701610495565b604086015190935090506001600160401b0381111561056157600080fd5b600082516105ef818460208701610471565b9190910192915050565b828152604060208201526000825180604084015261061e816060850160208701610471565b601f01601f1916919091016060019392505050565b60006020828403121561064557600080fd5b81516001600160e01b03198116811461040557600080fd5b634e487b7160e01b600052603260045260246000fdfe5369676e617475726556616c696461746f72237265636f7665725369676e6572" + ) + + @doc """ + Returns the creation (init) bytecode of the validator contract. + """ + @spec creation_code() :: binary() + def creation_code, do: @creation_code + + @doc """ + Encodes a deployless validation call for the given signer, hash and signature. + + Returns the binary to be used as the `data` of an `eth_call` with no `to` address: + the creation code followed by the ABI-encoded + `(address _signer, bytes32 _hash, bytes _signature)` constructor arguments. + """ + @spec encode_validation_call(<<_::160>>, <<_::256>>, binary()) :: binary() + def encode_validation_call( + <<_::binary-size(20)>> = signer, + <<_::binary-size(32)>> = hash, + signature + ) + when is_binary(signature) do + @creation_code <> + ABI.TypeEncoder.encode([signer, hash, signature], [:address, {:bytes, 32}, :bytes]) + end +end diff --git a/lib/ethers/signature.ex b/lib/ethers/signature.ex new file mode 100644 index 0000000..0218009 --- /dev/null +++ b/lib/ethers/signature.ex @@ -0,0 +1,195 @@ +defmodule Ethers.Signature do + @moduledoc """ + Universal signature verification: EOA (`ecrecover`), smart-contract wallets + ([ERC-1271](https://eips.ethereum.org/EIPS/eip-1271)) and counterfactual — + not-yet-deployed — wallets ([ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)). + + Smart-contract wallets (Safe, Coinbase Smart Wallet, ERC-4337/EIP-7702 accounts, ...) + cannot produce signatures that `ecrecover` validates. Backends which only use + `ecrecover`-based verification (`Ethers.PersonalMessage.verify/3`, + `Ethers.TypedData.valid_signature?/3`) silently reject all smart-wallet users. The + functions in this module verify *any* signature: + + 1. **Fast path** — for plain 65-byte ECDSA signatures, `ecrecover` runs locally first. + A match verifies without any RPC round-trip. + 2. **Universal path** — otherwise, one `eth_call` executes the + `Ethers.Contracts.UniversalSigValidator` contract *deploylessly* (no `to` address, + nothing gets deployed on chain). The validator unwraps ERC-6492 signatures + (counterfactually deploying the wallet inside the call), performs the ERC-1271 + `isValidSignature/2` check for contract accounts, and falls back to `ecrecover`. + + Verification outcome is a boolean inside an ok-tuple: `{:ok, false}` means the + signature is *invalid*, while `{:error, reason}` is reserved for transport/RPC + failures. There are deliberately no bang variants — raising would conflate an invalid + signature with an RPC failure. + + ## Example + + A backend verifying a wallet login message: + + ```elixir + {:ok, true} = Ethers.Signature.verify_message("Sign in to Example", signature, address) + + # With explicit RPC options + Ethers.Signature.verify_message("Sign in to Example", signature, address, + rpc_opts: [url: "https://eth.example.com"] + ) + ``` + """ + + import Ethers.RpcClient, only: [get_rpc_client: 1] + + alias Ethers.Contracts.UniversalSigValidator + alias Ethers.Types + alias Ethers.Utils + + @doc """ + Verifies a signature over a 32-byte digest against an address. + + Accepts any signature kind: plain 65-byte ECDSA (EOA), ERC-1271 (verified on-chain + via the account contract) and ERC-6492-wrapped (counterfactual wallets). Plain EOA + signatures that recover to `address` are verified locally without any RPC call. + + ## Parameters + + - `hash`: The 32-byte digest that was signed — either a raw binary or a `0x`-prefixed + hex string. (e.g. `Ethers.PersonalMessage.hash/1` or `Ethers.TypedData.hash/1`) + - `signature`: The signature as a `0x`-prefixed hex string or raw binary. + ERC-6492-wrapped signatures (longer than 65 bytes) are passed to the validator + untouched. + - `address`: The address to verify against (EOA or smart-contract account). + - `opts`: Options. + + ## Options + + - `:block`: The block number (integer) or block tag for the validation `eth_call`. + Defaults to `"latest"`. + - `:rpc_client`: The RPC Client to use. It should implement ethereum jsonRPC API. + default: Ethereumex.HttpClient + - `:rpc_opts`: Extra options to pass to rpc_client. (Like timeout, Server URL, etc.) + + ## Returns + + - `{:ok, true}` if the signature is valid for `address`. + - `{:ok, false}` if the signature is invalid. + - `{:error, reason}` on malformed input or RPC transport failure. + """ + @spec verify_hash(binary(), binary(), Types.t_address(), Keyword.t()) :: + {:ok, boolean()} | {:error, term()} + def verify_hash(hash, signature, address, opts \\ []) do + with {:ok, hash} <- normalize_hash(hash), + {:ok, signature} <- normalize_signature(signature), + {:ok, address_bin} <- normalize_address(address) do + if ecrecover_match?(hash, signature, address_bin) do + {:ok, true} + else + validate_on_chain(hash, signature, address_bin, opts) + end + end + end + + @doc """ + Verifies a signature over an [EIP-191](https://eips.ethereum.org/EIPS/eip-191) + personal message (the `personal_sign` scheme) against an address. + + Hashes `message` with `Ethers.PersonalMessage.hash/1` and delegates to + `verify_hash/4` — see it for accepted signature kinds, options and return values. + """ + @spec verify_message(binary(), binary(), Types.t_address(), Keyword.t()) :: + {:ok, boolean()} | {:error, term()} + def verify_message(message, signature, address, opts \\ []) when is_binary(message) do + verify_hash(Ethers.PersonalMessage.hash(message), signature, address, opts) + end + + @doc """ + Verifies a signature over [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed + structured data against an address. + + Hashes `typed_data` with `Ethers.TypedData.hash/1` and delegates to `verify_hash/4` — + see it for accepted signature kinds, options and return values. + """ + @spec verify_typed_data(Ethers.TypedData.t(), binary(), Types.t_address(), Keyword.t()) :: + {:ok, boolean()} | {:error, term()} + def verify_typed_data(%Ethers.TypedData{} = typed_data, signature, address, opts \\ []) do + verify_hash(Ethers.TypedData.hash(typed_data), signature, address, opts) + end + + defp normalize_hash(<<_::binary-size(32)>> = hash), do: {:ok, hash} + + defp normalize_hash(<<"0x", _::binary-64>> = hash) do + case Utils.hex_decode(hash) do + {:ok, decoded} -> {:ok, decoded} + :error -> {:error, :invalid_hash} + end + end + + defp normalize_hash(_hash), do: {:error, :invalid_hash} + + defp normalize_signature("0x" <> _ = signature) do + case Utils.hex_decode(signature) do + {:ok, decoded} -> {:ok, decoded} + :error -> {:error, :invalid_signature} + end + end + + defp normalize_signature(signature) when is_binary(signature), do: {:ok, signature} + + defp normalize_address(address) do + with true <- is_binary(address), + {:ok, address_bin} <- Utils.decode_address(address) do + {:ok, address_bin} + else + _ -> {:error, :invalid_address} + end + end + + defp ecrecover_match?(hash, <>, address_bin) + when v in [0, 1, 27, 28] do + recovery_id = if v in [1, 28], do: 1, else: 0 + + case Ethers.secp256k1_module().recover(hash, r, s, recovery_id) do + {:ok, public_key} -> + Utils.public_key_to_address(public_key, false) |> Utils.decode_address!() == address_bin + + {:error, _reason} -> + false + end + end + + defp ecrecover_match?(_hash, _signature, _address_bin), do: false + + defp validate_on_chain(hash, signature, address_bin, opts) do + {rpc_client, rpc_opts} = get_rpc_client(opts) + + data = UniversalSigValidator.encode_validation_call(address_bin, hash, signature) + + case rpc_client.eth_call(%{data: Utils.hex_encode(data)}, block(opts), rpc_opts) do + {:ok, "0x01"} -> {:ok, true} + {:ok, result} when result in ["0x00", "0x"] -> {:ok, false} + {:ok, result} -> {:error, {:unexpected_result, result}} + {:error, reason} -> handle_rpc_error(reason) + end + end + + # The validator reverts (rather than returning 0x00) for some invalid signatures — + # e.g. a malformed 65-byte signature on the ecrecover path. Execution reverts mean + # "invalid signature"; anything else is a real error and gets passed through. + defp handle_rpc_error(%{"code" => 3} = _reason), do: {:ok, false} + + defp handle_rpc_error(%{"message" => message} = reason) when is_binary(message) do + if String.contains?(String.downcase(message), "revert") do + {:ok, false} + else + {:error, reason} + end + end + + defp handle_rpc_error(reason), do: {:error, reason} + + defp block(opts) do + case Keyword.get(opts, :block, "latest") do + number when is_integer(number) -> Utils.integer_to_hex(number) + tag when is_binary(tag) -> tag + end + end +end diff --git a/test/ethers/signature_test.exs b/test/ethers/signature_test.exs new file mode 100644 index 0000000..235f491 --- /dev/null +++ b/test/ethers/signature_test.exs @@ -0,0 +1,423 @@ +defmodule Ethers.SignatureTest.RaisingRpcModule do + @moduledoc false + # Used to prove code paths that must not hit the network. + + def eth_call(_params, _block, _opts) do + raise "eth_call must not be called in this code path" + end +end + +defmodule Ethers.SignatureTest.ErrorRpcModule do + @moduledoc false + # Simulates an RPC transport failure. + + def eth_call(_params, _block, _opts), do: {:error, :nxdomain} +end + +defmodule Ethers.SignatureTest.MessageErrorRpcModule do + @moduledoc false + # Simulates JSON-RPC error responses carrying only a message (no error code). + + def eth_call(_params, _block, opts), do: {:error, %{"message" => opts[:message]}} +end + +defmodule Ethers.SignatureTest.EchoRpcModule do + @moduledoc false + # Sends the eth_call params back to the test process and returns a canned result. + + def eth_call(params, block, opts) do + send(opts[:send_params_to_pid], {:eth_call, params, block}) + {:ok, opts[:result] || "0x01"} + end +end + +defmodule Ethers.Contract.Test.ERC1271WalletContract do + @moduledoc false + use Ethers.Contract, abi_file: "tmp/erc1271_wallet_abi.json" +end + +defmodule Ethers.Contract.Test.Create2FactoryContract do + @moduledoc false + use Ethers.Contract, abi_file: "tmp/create2_factory_abi.json" +end + +defmodule Ethers.SignatureTest do + use ExUnit.Case + + import Ethers.TestHelpers + + alias Ethers.Contract.Test.Create2FactoryContract + alias Ethers.Contract.Test.ERC1271WalletContract + alias Ethers.Contracts.UniversalSigValidator + alias Ethers.PersonalMessage + alias Ethers.Signature + alias Ethers.SignatureTest.EchoRpcModule + alias Ethers.SignatureTest.ErrorRpcModule + alias Ethers.SignatureTest.MessageErrorRpcModule + alias Ethers.SignatureTest.RaisingRpcModule + alias Ethers.TypedData + alias Ethers.Utils + + # 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1 (same key used across the suite) + @owner_private_key "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" + @owner "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" + # First anvil dev account (funded, unlocked) — used to send deployment transactions + @from "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + @other_private_key "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + + @erc6492_magic_suffix <<0x6492649264926492649264926492649264926492649264926492649264926492::256>> + + defp typed_data do + TypedData.new!( + types: %{ + "Person" => [ + %{name: "name", type: "string"}, + %{name: "wallet", type: "address"} + ] + }, + primary_type: "Person", + domain: [name: "Ether Person", version: "1", chain_id: 1], + message: %{"name" => "Cow", "wallet" => "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"} + ) + end + + # Signs a raw 32-byte digest (no EIP-191 prefixing) with the given hex private key, + # returning a 65-byte `r ‖ s ‖ v` signature with v ∈ {27, 28}. + defp sign_digest(digest, private_key_hex) do + private_key = Utils.hex_decode!(private_key_hex) + {:ok, {r, s, recovery_id}} = Ethers.secp256k1_module().sign(digest, private_key) + r <> s <> <> + end + + defp deploy_wallet(owner) do + encoded_constructor = ERC1271WalletContract.constructor(owner) + deploy(ERC1271WalletContract, encoded_constructor: encoded_constructor, from: @from) + end + + defp wrap_6492(factory, factory_calldata, signature) do + [Utils.decode_address!(factory), factory_calldata, signature] + |> ABI.TypeEncoder.encode([:address, :bytes, :bytes]) + |> Kernel.<>(@erc6492_magic_suffix) + end + + describe "verify_hash/4 EOA fast path" do + test "verifies a personal message signature without any RPC call" do + {:ok, signature} = + Ethers.personal_sign("Hello 6492", + signer: Ethers.Signer.Local, + signer_opts: [private_key: @owner_private_key] + ) + + hash = PersonalMessage.hash("Hello 6492") + + assert {:ok, true} = + Signature.verify_hash(hash, signature, @owner, rpc_client: RaisingRpcModule) + end + + test "accepts raw binary signatures and hex hashes" do + hash = PersonalMessage.hash("raw binary") + signature = sign_digest(hash, @owner_private_key) + + assert {:ok, true} = + Signature.verify_hash(hash, signature, @owner, rpc_client: RaisingRpcModule) + + assert {:ok, true} = + Signature.verify_hash( + Utils.hex_encode(hash), + signature, + @owner, + rpc_client: RaisingRpcModule + ) + end + + test "ignores address casing" do + hash = PersonalMessage.hash("casing") + signature = sign_digest(hash, @owner_private_key) + + assert {:ok, true} = + Signature.verify_hash( + hash, + signature, + String.downcase(@owner), + rpc_client: RaisingRpcModule + ) + end + + test "accepts v ∈ {0, 1} parity signatures" do + hash = PersonalMessage.hash("parity") + <> = sign_digest(hash, @owner_private_key) + parity_signature = r_s <> <> + + assert {:ok, true} = + Signature.verify_hash(hash, parity_signature, @owner, rpc_client: RaisingRpcModule) + end + end + + describe "verify_hash/4 validator fallback (deployless eth_call)" do + test "returns false for a valid signature by a different address" do + hash = PersonalMessage.hash("wrong signer") + signature = sign_digest(hash, @other_private_key) + + assert {:ok, false} = Signature.verify_hash(hash, signature, @owner) + end + + test "returns false for a 65-byte garbage signature" do + hash = PersonalMessage.hash("garbage") + garbage = :binary.copy(<<1>>, 64) <> <<27>> + + assert {:ok, false} = Signature.verify_hash(hash, garbage, @owner) + end + + test "returns false for an all-zero signature (local recovery fails)" do + hash = PersonalMessage.hash("zeros") + zero_signature = :binary.copy(<<0>>, 64) <> <<27>> + + assert {:ok, false} = Signature.verify_hash(hash, zero_signature, @owner) + end + + test "returns false for a signature with invalid length (validator reverts)" do + hash = PersonalMessage.hash("short") + short = :binary.copy(<<1>>, 64) + + assert {:ok, false} = Signature.verify_hash(hash, short, @owner) + end + + test "sends a deployless call: no `to`, data = creation code <> constructor args" do + hash = PersonalMessage.hash("deployless") + signature = sign_digest(hash, @other_private_key) + + assert {:ok, true} = + Signature.verify_hash(hash, signature, @owner, + rpc_client: EchoRpcModule, + rpc_opts: [send_params_to_pid: self(), result: "0x01"] + ) + + assert_receive {:eth_call, params, "latest"} + refute Map.has_key?(params, :to) + + expected_prefix = Utils.hex_encode(UniversalSigValidator.creation_code()) + assert String.starts_with?(params.data, expected_prefix) + end + + test "supports the :block option" do + hash = PersonalMessage.hash("block option") + signature = sign_digest(hash, @other_private_key) + + assert {:ok, true} = + Signature.verify_hash(hash, signature, @owner, + block: 123, + rpc_client: EchoRpcModule, + rpc_opts: [send_params_to_pid: self(), result: "0x01"] + ) + + assert_receive {:eth_call, _params, "0x7B"} + end + + test "returns an error for unexpected validator results" do + hash = PersonalMessage.hash("unexpected") + signature = sign_digest(hash, @other_private_key) + + assert {:error, {:unexpected_result, "0xdeadbeef"}} = + Signature.verify_hash(hash, signature, @owner, + rpc_client: EchoRpcModule, + rpc_opts: [send_params_to_pid: self(), result: "0xdeadbeef"] + ) + end + end + + describe "ERC-1271 deployed smart-contract wallet" do + setup do + %{wallet: deploy_wallet(@owner)} + end + + test "verifies a signature by the wallet owner", %{wallet: wallet} do + hash = PersonalMessage.hash("hello 1271") + signature = sign_digest(hash, @owner_private_key) + + assert {:ok, true} = Signature.verify_hash(hash, signature, wallet) + end + + test "rejects a signature by a non-owner", %{wallet: wallet} do + hash = PersonalMessage.hash("hello 1271") + signature = sign_digest(hash, @other_private_key) + + assert {:ok, false} = Signature.verify_hash(hash, signature, wallet) + end + + test "verify_message/4 works for smart-contract wallets", %{wallet: wallet} do + message = "sign in please" + signature = sign_digest(PersonalMessage.hash(message), @owner_private_key) + + assert {:ok, true} = Signature.verify_message(message, signature, wallet) + assert {:ok, false} = Signature.verify_message("some other message", signature, wallet) + end + + test "verify_typed_data/4 works for smart-contract wallets", %{wallet: wallet} do + signature = sign_digest(TypedData.hash(typed_data()), @owner_private_key) + + assert {:ok, true} = Signature.verify_typed_data(typed_data(), signature, wallet) + end + end + + describe "ERC-6492 counterfactual (not-yet-deployed) wallet" do + setup do + factory = deploy(Create2FactoryContract, from: @from) + + salt = <<42::256>> + + init_code = + ERC1271WalletContract.__contract_binary__() <> + ABI.TypeEncoder.encode([Utils.decode_address!(@owner)], [:address]) + + counterfactual_address = + <<0xFF, Utils.decode_address!(factory)::binary, salt::binary, + Ethers.keccak_module().hash_256(init_code)::binary>> + |> Ethers.keccak_module().hash_256() + |> binary_part(12, 20) + |> Utils.encode_address!() + + factory_calldata = Create2FactoryContract.deploy(salt, init_code).data + + %{ + factory: factory, + salt: salt, + init_code: init_code, + counterfactual_address: counterfactual_address, + factory_calldata: factory_calldata + } + end + + test "verifies a wrapped signature for an undeployed wallet", ctx do + # The wallet must not exist yet + assert {:ok, "0x"} = + Ethereumex.HttpClient.eth_get_code(String.downcase(ctx.counterfactual_address)) + + hash = PersonalMessage.hash("counterfactual hello") + signature = sign_digest(hash, @owner_private_key) + wrapped = wrap_6492(ctx.factory, ctx.factory_calldata, signature) + + assert {:ok, true} = Signature.verify_hash(hash, wrapped, ctx.counterfactual_address) + + # verify_hash must not have deployed anything (eth_call only) + assert {:ok, "0x"} = + Ethereumex.HttpClient.eth_get_code(String.downcase(ctx.counterfactual_address)) + end + + test "accepts hex-encoded wrapped signatures", ctx do + hash = PersonalMessage.hash("hex wrapped") + signature = sign_digest(hash, @owner_private_key) + wrapped = Utils.hex_encode(wrap_6492(ctx.factory, ctx.factory_calldata, signature)) + + assert {:ok, true} = Signature.verify_hash(hash, wrapped, ctx.counterfactual_address) + end + + test "rejects a wrapped signature by a non-owner", ctx do + hash = PersonalMessage.hash("counterfactual hello") + signature = sign_digest(hash, @other_private_key) + wrapped = wrap_6492(ctx.factory, ctx.factory_calldata, signature) + + assert {:ok, false} = Signature.verify_hash(hash, wrapped, ctx.counterfactual_address) + end + end + + describe "verify_message/4 and verify_typed_data/4 (EOA)" do + test "verify_message verifies EOA signatures without RPC" do + {:ok, signature} = + Ethers.personal_sign("hi", + signer: Ethers.Signer.Local, + signer_opts: [private_key: @owner_private_key] + ) + + assert {:ok, true} = + Signature.verify_message("hi", signature, @owner, rpc_client: RaisingRpcModule) + end + + test "verify_message returns false for the wrong address" do + {:ok, signature} = + Ethers.personal_sign("hi", + signer: Ethers.Signer.Local, + signer_opts: [private_key: @other_private_key] + ) + + assert {:ok, false} = Signature.verify_message("hi", signature, @owner) + end + + test "verify_typed_data verifies EOA signatures without RPC" do + {:ok, signature} = + Ethers.sign_typed_data(typed_data(), + signer: Ethers.Signer.Local, + signer_opts: [private_key: @owner_private_key] + ) + + assert {:ok, true} = + Signature.verify_typed_data(typed_data(), signature, @owner, + rpc_client: RaisingRpcModule + ) + end + + test "verify_typed_data returns false for the wrong address" do + {:ok, signature} = + Ethers.sign_typed_data(typed_data(), + signer: Ethers.Signer.Local, + signer_opts: [private_key: @other_private_key] + ) + + assert {:ok, false} = Signature.verify_typed_data(typed_data(), signature, @owner) + end + end + + describe "error handling" do + test "propagates RPC transport errors" do + hash = PersonalMessage.hash("transport error") + signature = sign_digest(hash, @other_private_key) + + assert {:error, :nxdomain} = + Signature.verify_hash(hash, signature, @owner, rpc_client: ErrorRpcModule) + end + + test "treats revert-message-only RPC errors as an invalid signature" do + hash = PersonalMessage.hash("revert message") + signature = sign_digest(hash, @other_private_key) + + assert {:ok, false} = + Signature.verify_hash(hash, signature, @owner, + rpc_client: MessageErrorRpcModule, + rpc_opts: [message: "execution reverted: SignatureValidator"] + ) + end + + test "propagates non-revert RPC error messages" do + hash = PersonalMessage.hash("other message") + signature = sign_digest(hash, @other_private_key) + + assert {:error, %{"message" => "internal error"}} = + Signature.verify_hash(hash, signature, @owner, + rpc_client: MessageErrorRpcModule, + rpc_opts: [message: "internal error"] + ) + end + + test "rejects invalid hashes" do + signature = sign_digest(PersonalMessage.hash("x"), @owner_private_key) + + assert {:error, :invalid_hash} = Signature.verify_hash(<<1, 2, 3>>, signature, @owner) + assert {:error, :invalid_hash} = Signature.verify_hash("0x1234", signature, @owner) + + non_hex_hash = "0x" <> String.duplicate("zz", 32) + assert {:error, :invalid_hash} = Signature.verify_hash(non_hex_hash, signature, @owner) + end + + test "rejects non-hex-decodable signatures" do + hash = PersonalMessage.hash("x") + + assert {:error, :invalid_signature} = Signature.verify_hash(hash, "0xzz", @owner) + end + + test "rejects invalid addresses" do + hash = PersonalMessage.hash("x") + signature = sign_digest(hash, @owner_private_key) + + assert {:error, :invalid_address} = Signature.verify_hash(hash, signature, "0x1234") + end + end +end diff --git a/test/support/contracts/create2_factory.sol b/test/support/contracts/create2_factory.sol new file mode 100644 index 0000000..de9ae37 --- /dev/null +++ b/test/support/contracts/create2_factory.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +// Minimal CREATE2 factory used to test ERC-6492 counterfactual signature +// verification in `Ethers.Signature`. +contract Create2Factory { + function deploy( + bytes32 salt, + bytes memory initCode + ) public returns (address addr) { + assembly { + addr := create2(0, add(initCode, 0x20), mload(initCode), salt) + } + require(addr != address(0), "CREATE2 failed"); + } +} diff --git a/test/support/contracts/erc1271_wallet.sol b/test/support/contracts/erc1271_wallet.sol new file mode 100644 index 0000000..1305816 --- /dev/null +++ b/test/support/contracts/erc1271_wallet.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +// Minimal ERC-1271 smart-contract wallet used to test `Ethers.Signature`. +// Returns the ERC-1271 magic value when the signature over `hash` was produced +// by the configured owner EOA. +contract ERC1271Wallet { + bytes4 internal constant MAGICVALUE = 0x1626ba7e; + + address public owner; + + constructor(address _owner) { + owner = _owner; + } + + function isValidSignature( + bytes32 hash, + bytes memory signature + ) public view returns (bytes4) { + if (signature.length != 65) { + return 0xffffffff; + } + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + + if (v < 27) { + v += 27; + } + + if (ecrecover(hash, v, r, s) == owner) { + return MAGICVALUE; + } + + return 0xffffffff; + } +} From 63c965c0735c37bf1ad1d5423b9591b8d5a632c5 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sat, 18 Jul 2026 23:33:25 -0400 Subject: [PATCH 2/4] Tighten UniversalSigValidator specs for dialyzer underspecs flag Co-Authored-By: Claude Fable 5 --- lib/ethers/contracts/universal_sig_validator.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ethers/contracts/universal_sig_validator.ex b/lib/ethers/contracts/universal_sig_validator.ex index 2fa3cd8..03667b8 100644 --- a/lib/ethers/contracts/universal_sig_validator.ex +++ b/lib/ethers/contracts/universal_sig_validator.ex @@ -28,7 +28,7 @@ defmodule Ethers.Contracts.UniversalSigValidator do @doc """ Returns the creation (init) bytecode of the validator contract. """ - @spec creation_code() :: binary() + @spec creation_code() :: <<_::13_472>> def creation_code, do: @creation_code @doc """ @@ -38,7 +38,7 @@ defmodule Ethers.Contracts.UniversalSigValidator do the creation code followed by the ABI-encoded `(address _signer, bytes32 _hash, bytes _signature)` constructor arguments. """ - @spec encode_validation_call(<<_::160>>, <<_::256>>, binary()) :: binary() + @spec encode_validation_call(<<_::160>>, <<_::256>>, binary()) :: <<_::64, _::_*8>> def encode_validation_call( <<_::binary-size(20)>> = signer, <<_::binary-size(32)>> = hash, From e9605311b7a93128dbfd50ae3aedc29f27233939 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sun, 19 Jul 2026 01:18:42 -0400 Subject: [PATCH 3/4] Update lib/ethers/signature.ex --- lib/ethers/signature.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ethers/signature.ex b/lib/ethers/signature.ex index 0218009..ea24f69 100644 --- a/lib/ethers/signature.ex +++ b/lib/ethers/signature.ex @@ -164,7 +164,7 @@ defmodule Ethers.Signature do data = UniversalSigValidator.encode_validation_call(address_bin, hash, signature) case rpc_client.eth_call(%{data: Utils.hex_encode(data)}, block(opts), rpc_opts) do - {:ok, "0x01"} -> {:ok, true} + {:ok, result} when result in ["0x01", "0x1"] -> {:ok, true} {:ok, result} when result in ["0x00", "0x"] -> {:ok, false} {:ok, result} -> {:error, {:unexpected_result, result}} {:error, reason} -> handle_rpc_error(reason) From 594e19ea509278405434ca4217bc87536881638c Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sun, 19 Jul 2026 01:19:58 -0400 Subject: [PATCH 4/4] Use a variable for recovered address instead of piping into == Co-Authored-By: Claude Fable 5 --- lib/ethers/signature.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ethers/signature.ex b/lib/ethers/signature.ex index ea24f69..ea0e3b0 100644 --- a/lib/ethers/signature.ex +++ b/lib/ethers/signature.ex @@ -149,7 +149,12 @@ defmodule Ethers.Signature do case Ethers.secp256k1_module().recover(hash, r, s, recovery_id) do {:ok, public_key} -> - Utils.public_key_to_address(public_key, false) |> Utils.decode_address!() == address_bin + recovered_address_bin = + public_key + |> Utils.public_key_to_address(false) + |> Utils.decode_address!() + + recovered_address_bin == address_bin {:error, _reason} -> false