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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
51 changes: 51 additions & 0 deletions lib/ethers/contracts/universal_sig_validator.ex
Original file line number Diff line number Diff line change
@@ -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() :: <<_::13_472>>
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()) :: <<_::64, _::_*8>>
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
200 changes: 200 additions & 0 deletions lib/ethers/signature.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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, <<r::binary-size(32), s::binary-size(32), v>>, 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} ->
recovered_address_bin =
public_key
|> Utils.public_key_to_address(false)
|> Utils.decode_address!()

recovered_address_bin == 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, 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)
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
Loading
Loading