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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Enhancements

- Add [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) set-code transaction support (type 4)

## 0.7.0 (2026-07-20)

### Enhancements
Expand Down
158 changes: 157 additions & 1 deletion lib/ethers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ defmodule Ethers do
```
"""

alias Ethers.Authorization
alias Ethers.CombinedEventFilter
alias Ethers.Event
alias Ethers.EventFilter
Expand Down Expand Up @@ -606,6 +607,161 @@ defmodule Ethers do
end
end

@doc """
Signs an [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization and returns an
`Ethers.Authorization.Signed` struct ready for use in the `authorization_list` of a type-4
transaction (`Ethers.Transaction.Eip7702`).

Accepts either a ready `Ethers.Authorization` struct (signed as-is) or a map/keyword list of
authorization params. With params, missing fields are auto-fetched from the network:
`chain_id` via `eth_chainId` and `nonce` via `eth_getTransactionCount` of the authority (the
signing account, resolved from `signer_opts[:from]` or the signer's accounts).

The signer is resolved the same way as for transactions: the `:signer` option, otherwise
the `:default_signer` application env, otherwise `Ethers.Signer.JsonRPC`. Note that
`Ethers.Signer.JsonRPC` does not support authorization signing (no standard RPC method
exists) and returns `{:error, :not_supported}`.

## Options

- `:executor`: Set to `:self` when the authority itself will send the type-4 transaction
(self-sponsoring). The transaction increments the account nonce before authorizations are
applied, so the auto-fetched nonce is bumped by one. Defaults to `nil` (another account —
a sponsor/relayer — sends the transaction). An explicitly given `nonce` is never adjusted.
- `:signer`: The signer module to use. (e.g. `Ethers.Signer.Local`)
- `:signer_opts`: Options passed to the signer. Use `:from` here (and `:private_key` for
`Ethers.Signer.Local`) so the signer knows which account signs.
- `:rpc_client`: The RPC Client to use for auto-fetching missing fields.
- `:rpc_opts`: Specific RPC options to specify for auto-fetch requests.

## Examples

```elixir
# Fully specified authorization
{:ok, authorization} = Ethers.Authorization.new(chain_id: 1, address: delegate, nonce: 42)
Ethers.sign_authorization(authorization,
signer: Ethers.Signer.Local,
signer_opts: [private_key: "0x..."]
)
#=> {:ok, %Ethers.Authorization.Signed{...}}

# Auto-fetch chain_id and nonce; the authority sends the transaction itself
Ethers.sign_authorization(%{address: delegate},
executor: :self,
signer: Ethers.Signer.Local,
signer_opts: [private_key: "0x..."]
)
```
"""
@spec sign_authorization(Authorization.t() | map() | Keyword.t(), Keyword.t()) ::
{:ok, Authorization.Signed.t()} | {:error, term()}
def sign_authorization(authorization_or_params, opts \\ [])

def sign_authorization(%Authorization{} = authorization, 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_authorization(signer, authorization, build_signer_opts(%{}, opts))
end
end

def sign_authorization(params, opts) when is_map(params) or is_list(params) do
{executor, opts} = Keyword.pop(opts, :executor)

unless executor in [nil, :self] do
raise ArgumentError,
"invalid :executor option #{inspect(executor)} (only :self is supported)"
end

{opts, _} = Keyword.split(opts, @option_keys)

default_signer = default_signer() || Ethers.Signer.JsonRPC
params = Map.new(params)

with {:ok, signer} <- get_signer(opts, default_signer),
{:ok, params} <- fill_authorization_fields(params, executor, signer, opts),
{:ok, authorization} <- Authorization.new(params) do
do_sign_authorization(signer, authorization, build_signer_opts(%{}, opts))
end
end

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

_ ->
reraise error, __STACKTRACE__
end
end

defp fill_authorization_fields(params, executor, signer, opts) do
with {:ok, params} <- fill_authorization_chain_id(params, opts) do
fill_authorization_nonce(params, executor, signer, opts)
end
end

defp fill_authorization_chain_id(%{chain_id: chain_id} = params, _opts)
when not is_nil(chain_id),
do: {:ok, params}

defp fill_authorization_chain_id(params, opts) do
with {:ok, chain_id} <- chain_id(opts) do
{:ok, Map.put(params, :chain_id, chain_id)}
end
end

defp fill_authorization_nonce(%{nonce: nonce} = params, _executor, _signer, _opts)
when not is_nil(nonce),
do: {:ok, params}

defp fill_authorization_nonce(params, executor, signer, opts) do
with {:ok, authority} <- authorization_authority(signer, build_signer_opts(%{}, opts)),
{:ok, nonce} <- get_transaction_count(authority, Keyword.put(opts, :block, "latest")) do
# When the authority sends the type-4 transaction itself, its account nonce is
# incremented before authorizations are applied — the authorization must be signed
# over the next nonce.
nonce = if executor == :self, do: nonce + 1, else: nonce

{:ok, Map.put(params, :nonce, nonce)}
end
end

defp authorization_authority(signer, signer_opts) do
case Keyword.get(signer_opts, :from) do
nil ->
case signer.accounts(signer_opts) do
{:ok, [address | _]} -> {:ok, address}
{:ok, []} -> {:error, :no_accounts}
{:error, reason} -> {:error, reason}
end

from ->
{:ok, from}
end
end

@doc """
Same as `Ethers.sign_authorization/2` but raises on error.
"""
@spec sign_authorization!(Authorization.t() | map() | Keyword.t(), Keyword.t()) ::
Authorization.Signed.t() | no_return()
def sign_authorization!(authorization_or_params, opts \\ []) do
case sign_authorization(authorization_or_params, opts) do
{:ok, signed_authorization} -> signed_authorization
{:error, reason} -> raise ExecutionError, reason
end
end

@doc """
Makes an eth_estimate_gas rpc call with the given parameters and overrides.

Expand Down Expand Up @@ -708,7 +864,7 @@ defmodule Ethers do
- `:fromBlock` | `:from_block`: Minimum block number of logs to filter.
- `:toBlock` | `:to_block`: Maximum block number of logs to filter.
"""
@spec get_logs(map() | module(), Keyword.t()) :: {:ok, [Event.t()]} | {:error, term()}
@spec get_logs(map() | module(), Keyword.t()) :: {:ok, [Event.t()]} | {:error, term()}
def get_logs(event_filter, overrides \\ [])

def get_logs(events_module, overrides) when is_module(events_module) do
Expand Down
166 changes: 166 additions & 0 deletions lib/ethers/authorization.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
defmodule Ethers.Authorization do
@moduledoc """
[EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization.

An authorization is a signed permit by which an externally-owned account (the *authority*)
designates contract code to run in its place: once included in a type-4 transaction
(`Ethers.Transaction.Eip7702`), the authority's account code is set to the delegation
designator `0xef0100 ++ address`, making every call to the EOA execute the delegate
contract's code.

The signed payload is not the tuple itself but its EIP-7702 hash:

keccak256(0x05 ++ rlp([chain_id, address, nonce]))

To produce a signature use `Ethers.sign_authorization/2`, which routes through the
configured `Ethers.Signer`. The signed counterpart is `Ethers.Authorization.Signed`.

## Fields

- `chain_id` - chain where the authorization is valid. **`0` makes it valid on every
chain** — one signature delegates the authority everywhere (the nonce must still match
on each chain). Only use `0` deliberately.
- `address` - the delegate contract whose code the authority's account will run.
The zero address clears an existing delegation (see `clear/1`).
- `nonce` - the authority's account nonce **at the time the authorization is applied**
on chain. When the authority itself sends the type-4 transaction (self-sponsoring),
the transaction increments the account nonce first, so the authorization nonce must be
the current nonce **plus one** — see the `:executor` option of
`Ethers.sign_authorization/2`.

A mismatched nonce (or a signature with a high `s` value) does not fail the transaction;
the authorization is silently skipped on chain, so getting these right matters.
"""

import Ethers.Transaction.Helpers, only: [validate_non_neg_integer: 1, validate_address: 1]

alias Ethers.Types
alias Ethers.Utils

@magic <<0x05>>
@zero_address "0x0000000000000000000000000000000000000000"

# EIP-7702: authorization nonce must be < 2^64
@max_nonce 2 ** 64 - 1

@enforce_keys [:chain_id, :address, :nonce]
defstruct [:chain_id, :address, :nonce]

@typedoc """
An unsigned EIP-7702 authorization incorporating the following fields:
- `chain_id` - chain ID the authorization is valid on, or `0` for every chain
- `address` - the delegate contract address
- `nonce` - the authority's account nonce at the time the authorization applies
"""
@type t :: %__MODULE__{
chain_id: non_neg_integer(),
address: Types.t_address(),
nonce: non_neg_integer()
}

@doc """
Creates a new authorization struct with the given parameters.

Accepts a map or a keyword list with the `:chain_id`, `:address` and `:nonce` keys, all
required. See the module documentation for the field semantics.

## Examples

iex> Ethers.Authorization.new(chain_id: 1, address: "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1", nonce: 7)
{:ok, %Ethers.Authorization{chain_id: 1, address: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", nonce: 7}}
"""
@spec new(map() | Keyword.t()) :: {:ok, t()} | {:error, reason :: atom()}
def new(params) when is_list(params), do: params |> Map.new() |> new()

def new(params) when is_map(params) do
with :ok <- validate_required(params[:chain_id], :missing_chain_id),
:ok <- validate_required(params[:address], :missing_address),
:ok <- validate_required(params[:nonce], :missing_nonce),
:ok <- validate_non_neg_integer(params[:chain_id]),
:ok <- validate_non_neg_integer(params[:nonce]),
:ok <- validate_nonce_bound(params[:nonce]),
:ok <- validate_address(params[:address]) do
{:ok,
%__MODULE__{
chain_id: params[:chain_id],
address: Utils.to_checksum_address(params[:address]),
nonce: params[:nonce]
}}
end
end

@doc """
Same as `new/1` but raises on error.
"""
@spec new!(map() | Keyword.t()) :: t() | no_return()
def new!(params) do
case new(params) do
{:ok, authorization} -> authorization
{:error, reason} -> raise ArgumentError, "invalid authorization: #{inspect(reason)}"
end
end

@doc """
Creates an authorization that clears the authority's delegation.

Same as `new/1` with the zero address: applying it resets the authority's account code
to empty instead of writing a delegation designator. This is the only way to remove an
EIP-7702 delegation. The authority's nonce is still consumed.

## Examples

iex> Ethers.Authorization.clear(chain_id: 1, nonce: 8)
{:ok, %Ethers.Authorization{chain_id: 1, address: "0x0000000000000000000000000000000000000000", nonce: 8}}
"""
@spec clear(map() | Keyword.t()) :: {:ok, t()} | {:error, reason :: atom()}
def clear(params) do
params
|> Map.new()
|> Map.put(:address, @zero_address)
|> new()
end

@doc """
Same as `clear/1` but raises on error.
"""
@spec clear!(map() | Keyword.t()) :: t() | no_return()
def clear!(params) do
params
|> Map.new()
|> Map.put(:address, @zero_address)
|> new!()
end

@doc """
Calculates the EIP-7702 signing hash of an authorization.

Returns the 32-byte digest of `keccak256(0x05 ++ rlp([chain_id, address, nonce]))`.
"""
@spec hash(t()) :: <<_::256>>
def hash(%__MODULE__{} = authorization) do
encoded =
authorization
|> to_rlp_list()
|> ExRLP.encode()

Ethers.keccak_module().hash_256(@magic <> encoded)
end

@doc false
@spec to_rlp_list(t()) :: [binary() | non_neg_integer()]
def to_rlp_list(%__MODULE__{} = authorization) do
[
authorization.chain_id,
Utils.decode_address!(authorization.address),
authorization.nonce
]
end

defp validate_required(nil, error), do: {:error, error}
defp validate_required(_value, _error), do: :ok

defp validate_nonce_bound(nonce) when is_integer(nonce) and nonce > @max_nonce,
do: {:error, :nonce_out_of_range}

defp validate_nonce_bound(_nonce), do: :ok
end
Loading
Loading