diff --git a/CHANGELOG.md b/CHANGELOG.md index a5256f5..6a638b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/ethers.ex b/lib/ethers.ex index 2617e2f..181a03c 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -62,6 +62,7 @@ defmodule Ethers do ``` """ + alias Ethers.Authorization alias Ethers.CombinedEventFilter alias Ethers.Event alias Ethers.EventFilter @@ -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. @@ -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 diff --git a/lib/ethers/authorization.ex b/lib/ethers/authorization.ex new file mode 100644 index 0000000..67ce49a --- /dev/null +++ b/lib/ethers/authorization.ex @@ -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 diff --git a/lib/ethers/authorization/signed.ex b/lib/ethers/authorization/signed.ex new file mode 100644 index 0000000..ed6f3a6 --- /dev/null +++ b/lib/ethers/authorization/signed.ex @@ -0,0 +1,107 @@ +defmodule Ethers.Authorization.Signed do + @moduledoc """ + A signed [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization. + + Wraps an `Ethers.Authorization` together with its secp256k1 signature. Signed + authorizations are what goes into the `authorization_list` of a type-4 transaction + (`Ethers.Transaction.Eip7702`) and serialize to the on-wire tuple + `[chain_id, address, nonce, y_parity, r, s]`. + + Produce one with `Ethers.sign_authorization/2`. Use `recover_authority/1` to get the + address of the account that signed (the *authority* — the EOA whose code will be set). + """ + + alias Ethers.Authorization + alias Ethers.Utils + + @secp256k1n 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + @enforce_keys [:authorization, :signature_y_parity, :signature_r, :signature_s] + defstruct [:authorization, :signature_y_parity, :signature_r, :signature_s] + + @typedoc """ + A signed EIP-7702 authorization incorporating the following fields: + - `authorization` - the signed `Ethers.Authorization` payload + - `signature_y_parity` - signature recovery bit (`0` or `1`) + - `signature_r` - signature `r` value (big-endian binary) + - `signature_s` - signature `s` value (big-endian binary) + """ + @type t :: %__MODULE__{ + authorization: Authorization.t(), + signature_y_parity: 0 | 1, + signature_r: binary(), + signature_s: binary() + } + + @doc """ + Recovers the authority (signer) address of a signed authorization. + + Enforces the EIP-2 low-`s` rule that EIP-7702 mandates: a signature with + `s > secp256k1n / 2` returns `{:error, :invalid_signature_s}` because the chain would + silently skip such an authorization. + + ## Returns + - `{:ok, address}` with the checksummed authority address on success. + - `{:error, reason}` if the signature is malformed or recovery fails. + """ + @spec recover_authority(t()) :: {:ok, Ethers.Types.t_address()} | {:error, term()} + def recover_authority(%__MODULE__{authorization: %Authorization{} = authorization} = signed) do + digest = Authorization.hash(authorization) + + with {:ok, recovery_id} <- normalize_recovery_id(signed.signature_y_parity), + :ok <- validate_low_s(signed.signature_s), + {:ok, public_key} <- + Ethers.secp256k1_module().recover( + digest, + pad32(signed.signature_r), + pad32(signed.signature_s), + recovery_id + ) do + {:ok, Utils.public_key_to_address(public_key)} + end + end + + @doc false + @spec from_rlp_list([binary()]) :: {:ok, t()} | {:error, :authorization_decode_failed} + def from_rlp_list([chain_id, address, nonce, y_parity, r, s]) do + {:ok, + %__MODULE__{ + authorization: %Authorization{ + chain_id: :binary.decode_unsigned(chain_id), + address: Utils.encode_address!(address), + nonce: :binary.decode_unsigned(nonce) + }, + signature_y_parity: :binary.decode_unsigned(y_parity), + signature_r: r, + signature_s: s + }} + end + + def from_rlp_list(_rlp_list), do: {:error, :authorization_decode_failed} + + @doc false + @spec to_rlp_list(t()) :: [binary() | non_neg_integer()] + def to_rlp_list(%__MODULE__{} = signed) do + Authorization.to_rlp_list(signed.authorization) ++ + [ + signed.signature_y_parity, + Utils.remove_leading_zeros(signed.signature_r), + Utils.remove_leading_zeros(signed.signature_s) + ] + end + + defp validate_low_s(s) when is_binary(s) do + if :binary.decode_unsigned(s) > div(@secp256k1n, 2) do + {:error, :invalid_signature_s} + else + :ok + end + end + + 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} + + defp pad32(bin) when byte_size(bin) >= 32, do: bin + defp pad32(bin), do: <<0::size((32 - byte_size(bin)) * 8), bin::binary>> +end diff --git a/lib/ethers/signer.ex b/lib/ethers/signer.ex index 7000142..98c4733 100644 --- a/lib/ethers/signer.ex +++ b/lib/ethers/signer.ex @@ -20,10 +20,12 @@ 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`) - and the optional `c:personal_sign/2` callback to support signing + [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed structured data (see `Ethers.TypedData`), + 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 + `Ethers.PersonalMessage`) and the optional `c:sign_authorization/2` callback to support signing + [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorizations (see + `Ethers.Authorization`). Signers that do not implement them will simply not support those signing schemes. ## Globally Default Signer @@ -96,5 +98,21 @@ defmodule Ethers.Signer do @callback personal_sign(message :: binary(), opts :: Keyword.t()) :: {:ok, binary()} | {:error, reason :: term()} - @optional_callbacks sign_typed_data: 2, personal_sign: 2 + @doc """ + Signs an [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization and returns the + signed authorization. + + This is an optional callback. Signers that do not implement it do not support authorization + signing. Note that unlike the other signing callbacks this one does not return a hex + signature — it returns the `Ethers.Authorization.Signed` struct, ready for use in the + `authorization_list` of an `Ethers.Transaction.Eip7702` transaction. + + ## Parameters + - authorization: The authorization to sign. (An `Ethers.Authorization` struct) + - opts: Other options passed to the signer as `signer_opts`. + """ + @callback sign_authorization(authorization :: Ethers.Authorization.t(), opts :: Keyword.t()) :: + {:ok, Ethers.Authorization.Signed.t()} | {:error, reason :: term()} + + @optional_callbacks sign_typed_data: 2, personal_sign: 2, sign_authorization: 2 end diff --git a/lib/ethers/signer/json_rpc.ex b/lib/ethers/signer/json_rpc.ex index c41bcc0..183232a 100644 --- a/lib/ethers/signer/json_rpc.ex +++ b/lib/ethers/signer/json_rpc.ex @@ -70,4 +70,8 @@ defmodule Ethers.Signer.JsonRPC do rpc_module.request("eth_accounts", [], opts) end + + # No standard JSON-RPC method exists for signing EIP-7702 authorizations. + @impl true + def sign_authorization(_authorization, _opts), do: {:error, :not_supported} end diff --git a/lib/ethers/signer/local.ex b/lib/ethers/signer/local.ex index 13c482b..cd2d604 100644 --- a/lib/ethers/signer/local.ex +++ b/lib/ethers/signer/local.ex @@ -17,6 +17,7 @@ defmodule Ethers.Signer.Local do import Ethers, only: [secp256k1_module: 0, keccak_module: 0] + alias Ethers.Authorization alias Ethers.Transaction alias Ethers.Transaction.Signed alias Ethers.Utils @@ -65,6 +66,23 @@ defmodule Ethers.Signer.Local do end end + @impl true + def sign_authorization(%Authorization{} = authorization, opts) do + digest = Authorization.hash(authorization) + + 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, + %Authorization.Signed{ + authorization: authorization, + signature_y_parity: recovery_id, + signature_r: r, + signature_s: s + }} + end + end + @impl true def accounts(opts) do with {:ok, private_key} <- private_key(opts), @@ -82,6 +100,9 @@ defmodule Ethers.Signer.Local do @impl true def personal_sign(_message, _opts), do: {:error, :secp256k1_module_not_loaded} + @impl true + def sign_authorization(_authorization, _opts), do: {:error, :secp256k1_module_not_loaded} + @impl true def accounts(_opts), do: {:error, :secp256k1_module_not_loaded} end diff --git a/lib/ethers/transaction.ex b/lib/ethers/transaction.ex index e078cdb..66b19df 100644 --- a/lib/ethers/transaction.ex +++ b/lib/ethers/transaction.ex @@ -8,15 +8,17 @@ defmodule Ethers.Transaction do - Handle different transaction types (legacy, EIP-1559, etc.) """ + alias Ethers.Authorization alias Ethers.Transaction.Eip1559 alias Ethers.Transaction.Eip2930 alias Ethers.Transaction.Eip4844 + alias Ethers.Transaction.Eip7702 alias Ethers.Transaction.Legacy alias Ethers.Transaction.Protocol, as: TxProtocol alias Ethers.Transaction.Signed alias Ethers.Utils - @default_transaction_types [Eip1559, Eip2930, Eip4844, Legacy] + @default_transaction_types [Eip1559, Eip2930, Eip4844, Eip7702, Legacy] @transaction_types Application.compile_env( :ethers, @@ -28,6 +30,7 @@ defmodule Ethers.Transaction do @rpc_fields %{ access_list: :accessList, + authorization_list: :authorizationList, blob_versioned_hashes: :blobVersionedHashes, chain_id: :chainId, gas_price: :gasPrice, @@ -261,6 +264,8 @@ defmodule Ethers.Transaction do # Convert from RPC-style field names to EVM field names. new(%{ access_list: from_map_value(tx, :accessList), + authorization_list: + tx |> from_map_value(:authorizationList) |> from_rpc_authorization_list(), blob_versioned_hashes: from_map_value(tx, :blobVersionedHashes), block_hash: from_map_value(tx, :blockHash), block_number: from_map_value_int(tx, :blockNumber), @@ -317,6 +322,9 @@ defmodule Ethers.Transaction do {:access_list, al} when is_list(al) -> {:access_list, encode_access_list(al)} + {:authorization_list, authorization_list} when is_list(authorization_list) -> + {:authorization_list, encode_authorization_list(authorization_list)} + {:blob_versioned_hashes, hashes} when is_list(hashes) -> {:blob_versioned_hashes, Enum.map(hashes, &Utils.hex_encode/1)} @@ -348,6 +356,19 @@ defmodule Ethers.Transaction do end) end + defp encode_authorization_list(authorization_list) do + Enum.map(authorization_list, fn %Authorization.Signed{authorization: authorization} = signed -> + %{ + chainId: Utils.integer_to_hex(authorization.chain_id), + address: authorization.address, + nonce: Utils.integer_to_hex(authorization.nonce), + yParity: Utils.integer_to_hex(signed.signature_y_parity), + r: Utils.hex_encode(signed.signature_r), + s: Utils.hex_encode(signed.signature_s) + } + end) + end + @doc false @deprecated "Use Transaction.Signed.calculate_y_parity_or_v/2 instead" defdelegate calculate_y_parity_or_v(tx, recovery_id), to: Signed @@ -419,6 +440,23 @@ defmodule Ethers.Transaction do Map.get_lazy(tx, key, fn -> Map.get(tx, to_string(key)) end) end + defp from_rpc_authorization_list(nil), do: nil + + defp from_rpc_authorization_list(authorization_list) when is_list(authorization_list) do + Enum.map(authorization_list, fn authorization -> + %Authorization.Signed{ + authorization: %Authorization{ + chain_id: from_map_value_int(authorization, :chainId), + address: authorization |> from_map_value(:address) |> Utils.to_checksum_address(), + nonce: from_map_value_int(authorization, :nonce) + }, + signature_y_parity: from_map_value_int(authorization, :yParity), + signature_r: from_map_value_bin(authorization, :r), + signature_s: from_map_value_bin(authorization, :s) + } + end) + end + @doc false def default_transaction_type, do: @default_transaction_type diff --git a/lib/ethers/transaction/eip7702.ex b/lib/ethers/transaction/eip7702.ex new file mode 100644 index 0000000..a3dfa4a --- /dev/null +++ b/lib/ethers/transaction/eip7702.ex @@ -0,0 +1,194 @@ +defmodule Ethers.Transaction.Eip7702 do + @moduledoc """ + Transaction struct and protocol implementation for Ethereum Improvement Proposal (EIP) 7702 + transactions. EIP-7702 introduced "set code transactions" which let externally-owned + accounts designate contract code to execute in their place via signed authorizations + (see `Ethers.Authorization`). + + Two constraints set this type apart from EIP-1559 transactions: + - `to` is required — contract creation is not allowed in type-4 transactions. + - `authorization_list` must contain at least one `Ethers.Authorization.Signed`. + + See: https://eips.ethereum.org/EIPS/eip-7702 + """ + + import Ethers.Transaction.Helpers + + alias Ethers.Authorization + alias Ethers.Types + alias Ethers.Utils + + @behaviour Ethers.Transaction + + @type_id 4 + + @enforce_keys [:chain_id, :nonce, :max_priority_fee_per_gas, :max_fee_per_gas, :gas, :to] + defstruct [ + :chain_id, + :nonce, + :max_priority_fee_per_gas, + :max_fee_per_gas, + :gas, + :to, + :value, + :input, + access_list: [], + authorization_list: [] + ] + + @typedoc """ + A transaction type following EIP-7702 (Type-4) and incorporating the following fields: + - `chain_id` - chain ID of network where the transaction is to be executed + - `nonce` - sequence number for the transaction from this sender + - `max_priority_fee_per_gas` - maximum fee per gas (in wei) to give to validators as priority fee (introduced in EIP-1559) + - `max_fee_per_gas` - maximum total fee per gas (in wei) willing to pay (introduced in EIP-1559) + - `gas` - maximum amount of gas allowed for transaction execution + - `to` - destination address for transaction. Required — type-4 transactions cannot create contracts + - `value` - amount of ether (in wei) to transfer + - `input` - data payload of the transaction + - `access_list` - list of addresses and storage keys to warm up (introduced in EIP-2930) + - `authorization_list` - list of signed authorizations setting code on their authorities (introduced in EIP-7702) + """ + @type t :: %__MODULE__{ + chain_id: non_neg_integer(), + nonce: non_neg_integer(), + max_priority_fee_per_gas: non_neg_integer(), + max_fee_per_gas: non_neg_integer(), + gas: non_neg_integer(), + to: Types.t_address(), + value: non_neg_integer(), + input: binary(), + access_list: [{binary(), [binary()]}], + authorization_list: [Authorization.Signed.t()] + } + + @impl Ethers.Transaction + def new(params) do + input = params[:input] || params[:data] || "" + value = params[:value] || 0 + + with :ok <- validate_common_fields(params), + :ok <- validate_to_required(params[:to]), + :ok <- validate_non_neg_integer(params.max_priority_fee_per_gas), + :ok <- validate_non_neg_integer(params.max_fee_per_gas), + :ok <- validate_non_neg_integer(value), + :ok <- validate_binary(input), + :ok <- validate_authorization_list(params[:authorization_list]) do + {:ok, + %__MODULE__{ + chain_id: params.chain_id, + nonce: params.nonce, + max_priority_fee_per_gas: params.max_priority_fee_per_gas, + max_fee_per_gas: params.max_fee_per_gas, + gas: params.gas, + to: Utils.to_checksum_address(params.to), + value: value, + input: input, + access_list: params[:access_list] || [], + authorization_list: params.authorization_list + }} + end + end + + @impl Ethers.Transaction + def auto_fetchable_fields do + [:chain_id, :nonce, :max_priority_fee_per_gas, :max_fee_per_gas, :gas] + end + + @impl Ethers.Transaction + def type_envelope, do: <> + + @impl Ethers.Transaction + def type_id, do: @type_id + + @impl Ethers.Transaction + def from_rlp_list([ + chain_id, + nonce, + max_priority_fee_per_gas, + max_fee_per_gas, + gas, + to, + value, + input, + access_list, + authorization_list + | rest + ]) do + with {:ok, to} <- decode_to_address(to), + {:ok, authorization_list} <- decode_authorization_list(authorization_list) do + {:ok, + %__MODULE__{ + chain_id: :binary.decode_unsigned(chain_id), + nonce: :binary.decode_unsigned(nonce), + max_priority_fee_per_gas: :binary.decode_unsigned(max_priority_fee_per_gas), + max_fee_per_gas: :binary.decode_unsigned(max_fee_per_gas), + gas: :binary.decode_unsigned(gas), + to: to, + value: :binary.decode_unsigned(value), + input: input, + access_list: access_list, + authorization_list: authorization_list + }, rest} + end + end + + def from_rlp_list(_rlp_list), do: {:error, :transaction_decode_failed} + + defp decode_to_address(""), do: {:error, :missing_to_address} + defp decode_to_address(to), do: Utils.encode_address(to) + + defp decode_authorization_list(authorization_list) when is_list(authorization_list) do + authorization_list + |> Enum.reduce_while([], fn rlp_list, acc -> + case Authorization.Signed.from_rlp_list(rlp_list) do + {:ok, authorization} -> {:cont, [authorization | acc]} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:error, reason} -> {:error, reason} + authorizations -> {:ok, Enum.reverse(authorizations)} + end + end + + defp decode_authorization_list(_authorization_list), do: {:error, :transaction_decode_failed} + + defp validate_to_required(nil), do: {:error, :missing_to_address} + defp validate_to_required(_to), do: :ok + + defp validate_authorization_list([%Authorization.Signed{} | _] = authorization_list) do + if Enum.all?(authorization_list, &match?(%Authorization.Signed{}, &1)) do + :ok + else + {:error, :invalid_authorization_list} + end + end + + defp validate_authorization_list(list) when list == [] or is_nil(list), + do: {:error, :empty_authorization_list} + + defp validate_authorization_list(_invalid), do: {:error, :invalid_authorization_list} + + defimpl Ethers.Transaction.Protocol do + def type_id(_transaction), do: @for.type_id() + + def type_envelope(_transaction), do: @for.type_envelope() + + def to_rlp_list(tx, _mode) do + # Eip7702 requires Eip1559 fields + [ + tx.chain_id, + tx.nonce, + tx.max_priority_fee_per_gas, + tx.max_fee_per_gas, + tx.gas, + (tx.to && Utils.decode_address!(tx.to)) || "", + tx.value, + tx.input, + tx.access_list || [], + Enum.map(tx.authorization_list, &Authorization.Signed.to_rlp_list/1) + ] + end + end +end diff --git a/test/ethers/authorization_test.exs b/test/ethers/authorization_test.exs new file mode 100644 index 0000000..371c211 --- /dev/null +++ b/test/ethers/authorization_test.exs @@ -0,0 +1,171 @@ +defmodule Ethers.AuthorizationTest do + use ExUnit.Case + + alias Ethers.Authorization + alias Ethers.Utils + + doctest Ethers.Authorization + + @delegate "0x2222222222222222222222222222222222222222" + # Anvil dev account #0 + @authority "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + + # Signed authorization fixtures produced independently with foundry: + # cast wallet sign-auth 0x2222222222222222222222222222222222222222 \ + # --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + # --nonce 7 --chain 1 + @signed_auth_rlp "0xf85a019422222222222222222222222222222222222222220780" <> + "a053ed0e60c809fbe7923273de996841403c594d6bd22ecba947fee441f282126a" <> + "a038b5b156e99e1b46658a2c1f4240cb8216d876996d3e8882f34846ff81410069" + + # cast wallet sign-auth 0x2222222222222222222222222222222222222222 \ + # --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + # --nonce 0 --chain 0 + @signed_auth_chain0_rlp "0xf85a809422222222222222222222222222222222222222228001" <> + "a0045d07efb041a71d5882ab83f6db7462711c706243275951b4959e50c0410524" <> + "a01fbf04884ced7bf18a7679a9a96362d1e0138801daa2ab4b7ba970721de6d5c6" + + @secp256k1n 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + describe "new/1" do + test "creates an authorization from a map or keyword list" do + assert {:ok, %Authorization{chain_id: 1, address: address, nonce: 7}} = + Authorization.new(%{chain_id: 1, address: @delegate, nonce: 7}) + + assert address == Utils.to_checksum_address(@delegate) + + assert {:ok, %Authorization{}} = + Authorization.new(chain_id: 1, address: @delegate, nonce: 7) + end + + test "returns an error for missing fields" do + assert {:error, :missing_chain_id} = Authorization.new(address: @delegate, nonce: 7) + assert {:error, :missing_address} = Authorization.new(chain_id: 1, nonce: 7) + assert {:error, :missing_nonce} = Authorization.new(chain_id: 1, address: @delegate) + end + + test "returns an error for invalid values" do + assert {:error, :expected_non_neg_integer_value} = + Authorization.new(chain_id: -1, address: @delegate, nonce: 7) + + assert {:error, :expected_non_neg_integer_value} = + Authorization.new(chain_id: 1, address: @delegate, nonce: "7") + + assert {:error, :nonce_out_of_range} = + Authorization.new(chain_id: 1, address: @delegate, nonce: 2 ** 64) + + assert {:error, :invalid_address} = + Authorization.new(chain_id: 1, address: "not an address", nonce: 7) + + assert {:error, :invalid_address_length} = + Authorization.new(chain_id: 1, address: "0x1234", nonce: 7) + end + + test "new!/1 raises on error" do + assert_raise ArgumentError, ~r/missing_address/, fn -> + Authorization.new!(chain_id: 1, nonce: 7) + end + end + end + + describe "clear/1" do + test "creates an authorization for the zero address" do + assert {:ok, %Authorization{address: "0x0000000000000000000000000000000000000000"}} = + Authorization.clear(chain_id: 1, nonce: 8) + + assert %Authorization{address: "0x0000000000000000000000000000000000000000"} = + Authorization.clear!(chain_id: 1, nonce: 8) + end + + test "overrides any given address" do + assert {:ok, %Authorization{address: "0x0000000000000000000000000000000000000000"}} = + Authorization.clear(chain_id: 1, address: @delegate, nonce: 8) + end + end + + describe "hash/1" do + test "calculates the EIP-7702 signing hash" do + # Expected value computed independently with foundry: + # cast keccak "0x05$(cast to-rlp '["0x01","0x2222...2222","0x07"]' | cut -c3-)" + authorization = Authorization.new!(chain_id: 1, address: @delegate, nonce: 7) + + assert Utils.hex_encode(Authorization.hash(authorization)) == + "0xeb1cce4707677a1968b78c5e74535d51773d66a8d2e291718937e3ddb3d44386" + end + end + + describe "Signed.from_rlp_list/1" do + test "decodes a signed authorization produced by cast" do + rlp_list = @signed_auth_rlp |> Utils.hex_decode!() |> ExRLP.decode() + + assert {:ok, %Authorization.Signed{} = signed} = + Authorization.Signed.from_rlp_list(rlp_list) + + assert signed.authorization == Authorization.new!(chain_id: 1, address: @delegate, nonce: 7) + assert signed.signature_y_parity == 0 + + assert Utils.hex_encode(signed.signature_r) == + "0x53ed0e60c809fbe7923273de996841403c594d6bd22ecba947fee441f282126a" + + assert Utils.hex_encode(signed.signature_s) == + "0x38b5b156e99e1b46658a2c1f4240cb8216d876996d3e8882f34846ff81410069" + + # Re-encoding produces the exact same bytes + assert signed |> Authorization.Signed.to_rlp_list() |> ExRLP.encode() == + Utils.hex_decode!(@signed_auth_rlp) + end + + test "returns an error for invalid RLP lists" do + assert {:error, :authorization_decode_failed} = Authorization.Signed.from_rlp_list([]) + assert {:error, :authorization_decode_failed} = Authorization.Signed.from_rlp_list(["", ""]) + end + end + + describe "Signed.recover_authority/1" do + test "recovers the authority address" do + {:ok, signed} = + @signed_auth_rlp + |> Utils.hex_decode!() + |> ExRLP.decode() + |> Authorization.Signed.from_rlp_list() + + assert {:ok, @authority} = Authorization.Signed.recover_authority(signed) + end + + test "recovers the authority of a chain-agnostic (chain_id 0) authorization" do + {:ok, signed} = + @signed_auth_chain0_rlp + |> Utils.hex_decode!() + |> ExRLP.decode() + |> Authorization.Signed.from_rlp_list() + + assert signed.authorization.chain_id == 0 + assert signed.signature_y_parity == 1 + assert {:ok, @authority} = Authorization.Signed.recover_authority(signed) + end + + test "rejects high-s signatures which the chain would silently skip" do + {:ok, signed} = + @signed_auth_rlp + |> Utils.hex_decode!() + |> ExRLP.decode() + |> Authorization.Signed.from_rlp_list() + + high_s = @secp256k1n - :binary.decode_unsigned(signed.signature_s) + malleated = %{signed | signature_s: :binary.encode_unsigned(high_s)} + + assert {:error, :invalid_signature_s} = Authorization.Signed.recover_authority(malleated) + end + + test "rejects invalid y_parity values" do + {:ok, signed} = + @signed_auth_rlp + |> Utils.hex_decode!() + |> ExRLP.decode() + |> Authorization.Signed.from_rlp_list() + + assert {:error, :invalid_signature} = + Authorization.Signed.recover_authority(%{signed | signature_y_parity: 5}) + end + end +end diff --git a/test/ethers/signer/local_test.exs b/test/ethers/signer/local_test.exs index a989d5f..17dc6f5 100644 --- a/test/ethers/signer/local_test.exs +++ b/test/ethers/signer/local_test.exs @@ -1,6 +1,7 @@ defmodule Ethers.Signer.LocalTest do use ExUnit.Case + alias Ethers.Authorization alias Ethers.Signer alias Ethers.Transaction.Eip1559 alias Ethers.Utils @@ -187,4 +188,66 @@ defmodule Ethers.Signer.LocalTest do assert {:error, :invalid_private_key} == Signer.Local.accounts(private_key: "invalid") end end + + describe "sign_authorization/2" do + test "produces the exact signature for a fixed key and authorization" do + # Expected values produced independently by foundry for the same private key + # (@private_key -> 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1): + # cast wallet sign-auth 0x2222222222222222222222222222222222222222 \ + # --private-key $PK --nonce 7 --chain 1 + authorization = + Authorization.new!( + chain_id: 1, + address: "0x2222222222222222222222222222222222222222", + nonce: 7 + ) + + assert {:ok, %Authorization.Signed{} = signed} = + Signer.Local.sign_authorization(authorization, private_key: @private_key) + + assert signed.authorization == authorization + assert signed.signature_y_parity == 0 + + assert Utils.hex_encode(signed.signature_r) == + "0x9d984a806a802bf0c73b1b7e126fc5ee241ebeba33d4adfb0ba595ed21791968" + + assert Utils.hex_encode(signed.signature_s) == + "0x194dd94cb56eb4f90accaed679537fefa8d0f2f1f24632ea868ffbedd9ed99ee" + + assert {:ok, "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"} = + Authorization.Signed.recover_authority(signed) + end + + test "produces the exact signature for a clear (zero address) authorization" do + # cast wallet sign-auth 0x0000000000000000000000000000000000000000 \ + # --private-key $PK --nonce 0 --chain 0 + authorization = Authorization.clear!(chain_id: 0, nonce: 0) + + assert {:ok, %Authorization.Signed{} = signed} = + Signer.Local.sign_authorization(authorization, private_key: @private_key) + + assert signed.signature_y_parity == 1 + + assert Utils.hex_encode(signed.signature_r) == + "0x8bc16de717c99f343ccdc7acdf16d8c46ab5de7a50e3504d1c4c28d1a529153f" + + assert Utils.hex_encode(signed.signature_s) == + "0x44743ce31ed05643786db455603ddb30a7a788309bc967a2007464ae3090511b" + end + + test "returns :wrong_key when :from does not match the private key" do + authorization = + Authorization.new!( + chain_id: 1, + address: "0x2222222222222222222222222222222222222222", + nonce: 7 + ) + + assert {:error, :wrong_key} = + Signer.Local.sign_authorization(authorization, + private_key: @private_key, + from: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + ) + end + end end diff --git a/test/ethers/transaction_test.exs b/test/ethers/transaction_test.exs index 3ec9d50..4775e24 100644 --- a/test/ethers/transaction_test.exs +++ b/test/ethers/transaction_test.exs @@ -149,6 +149,92 @@ defmodule Ethers.TransactionTest do assert Ethers.Utils.hex_encode(Transaction.encode(decoded_tx)) == raw_tx end + test "decodes raw EIP-7702 transaction and re-encodes it correctly" do + # Mainnet transaction 0xafc5627648e9d6944f3087e4aeeb11f950de4b0e64ee0caccf7552ba03e01c7e + # (block 25639950): a sponsor sends a type-4 transaction to the authority EOA, which + # delegates to 0x69e6bd1C4082403Fc7917a61F6216552fC1a541D and is then called directly. + raw_tx = + "0x04f903d101829b81845f355f55845f355f558304016494bcb73594df46001f29143f6853c965684cdaf80380b9030432fba9bb000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000d7eefee617ef6251072294e4a0090b816e1b8f3b00000000000000000000000000000000000000000000000000000000004c4b4000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000009968f86863cd0b7ce2f965fba2ed2749aa36416f0000000000000000000000000000000000000000000000000000000000153eb6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041355d1980ed3a88c4dd908723d87313763909184a08b2103f4bfcdb0ba4533eb12ef4968726b9b300e194562bcb0101c19a17a53770251ea8ed1bc6d56d27c9c31b00000000000000000000000000000000000000000000000000000000000000c0f85cf85a019469e6bd1c4082403fc7917a61f6216552fc1a541d0280a03c67c1f1203dd722e316796c37c4b828b98f5a27578627879f415763e2ac5afda04c77aba7874855a80c7357fb13ae953cf9d819a615c00baaa15a2e165a0f08f701a0fcb1caef5bca448a1c02f63ed1e94343bb60cb320f8e3642ef8ceee890b5dc61a05badaf50be7726ae1d0315098e7212492f2b2978685376e4b0ab9ad7c5455eea" + + expected_from = "0x613874e81478ef4213c0ca3fb768b878adb6fd8b" + expected_hash = "0xafc5627648e9d6944f3087e4aeeb11f950de4b0e64ee0caccf7552ba03e01c7e" + + assert {:ok, decoded_tx} = Transaction.decode(raw_tx) + assert %Transaction.Signed{payload: %Transaction.Eip7702{}} = decoded_tx + + # Verify transaction hash matches + assert Transaction.transaction_hash(decoded_tx) == expected_hash + + # Verify recovered from address + recovered_from = Transaction.Signed.from_address(decoded_tx) + assert String.downcase(recovered_from) == String.downcase(expected_from) + + # Verify other transaction fields + assert decoded_tx.payload.chain_id == 1 + assert decoded_tx.payload.gas == 262_500 + assert decoded_tx.payload.max_fee_per_gas == 1_597_333_333 + assert decoded_tx.payload.max_priority_fee_per_gas == 1_597_333_333 + assert decoded_tx.payload.nonce == 39_809 + assert decoded_tx.payload.to == "0xBCb73594df46001F29143f6853C965684cdAF803" + assert decoded_tx.payload.value == 0 + assert decoded_tx.payload.access_list == [] + + # Verify authorization list + assert [%Ethers.Authorization.Signed{} = signed_auth] = + decoded_tx.payload.authorization_list + + assert signed_auth.authorization == + Ethers.Authorization.new!( + chain_id: 1, + address: "0x69e6bd1C4082403Fc7917a61F6216552fC1a541D", + nonce: 2 + ) + + assert signed_auth.signature_y_parity == 0 + + assert Utils.hex_encode(signed_auth.signature_r) == + "0x3c67c1f1203dd722e316796c37c4b828b98f5a27578627879f415763e2ac5afd" + + assert Utils.hex_encode(signed_auth.signature_s) == + "0x4c77aba7874855a80c7357fb13ae953cf9d819a615c00baaa15a2e165a0f08f7" + + # The authority is the account the transaction is sent to (sponsored delegation) — + # its code on mainnet is the designator 0xef0100 ++ 69e6bd1c... + assert {:ok, "0xBCb73594df46001F29143f6853C965684cdAF803"} = + Ethers.Authorization.Signed.recover_authority(signed_auth) + + assert Ethers.Utils.hex_encode(Transaction.encode(decoded_tx)) == raw_tx + end + + test "returns error for EIP-7702 transaction with empty to address" do + # Type-4 transactions cannot create contracts, so an empty `to` must be rejected + authorization_rlp = [ + <<1>>, + <<0x69E6BD1C4082403FC7917A61F6216552FC1A541D::160>>, + <<2>>, + "", + <<1>>, + <<1>> + ] + + raw_tx = + <<4>> <> + ExRLP.encode([ + <<1>>, + "", + <<1>>, + <<1>>, + <<0x5208::16>>, + "", + "", + "", + [], + [authorization_rlp] + ]) + + assert {:error, :missing_to_address} = Transaction.decode(raw_tx) + end + test "decodes raw EIP-1559 transaction and re-encodes it correctly" do raw_tx = "0x02f8af0177837a12008502c4bfbc3282f88c948881562783028f5c1bcb985d2283d5e170d8888880b844a9059cbb0000000000000000000000002ef7f5c7c727d8845e685f462a5b4f8ac4972a6700000000000000000000000000000000000000000000051ab2ea6fbbb7420000c001a007280557e86f690290f9ea9e26cc17e0cf09a17f6c2d041e95b33be4b81888d0a06c7a24e8fba5cceb455b19950849b9733f0deb92d7e8c2a919f4a82df9c6036a" @@ -241,23 +327,37 @@ defmodule Ethers.TransactionTest do Transaction.Legacy, Transaction.Eip1559, Transaction.Eip2930, - Transaction.Eip4844 + Transaction.Eip4844, + Transaction.Eip7702 ] + signed_authorization = %Ethers.Authorization.Signed{ + authorization: + Ethers.Authorization.new!( + chain_id: 1, + address: "0x2222222222222222222222222222222222222222", + nonce: 0 + ), + signature_y_parity: 0, + signature_r: <<1::256>>, + signature_s: <<2::256>> + } + for type <- types do {:ok, tx} = type.new(%{ nonce: 0, gas_price: 1, gas: 21_000, - to: nil, + to: "0x2222222222222222222222222222222222222222", value: 0, input: "", chain_id: 1, max_priority_fee_per_gas: 1, max_fee_per_blob_gas: 1, max_fee_per_gas: 1, - access_list: [] + access_list: [], + authorization_list: [signed_authorization] }) {:ok, signed_tx} = @@ -412,5 +512,75 @@ defmodule Ethers.TransactionTest do assert transaction.input == Utils.hex_decode!("0x112233") assert %Transaction.Eip2930{} = transaction end + + test "handles EIP-7702 transaction type" do + # Authorization entry shaped exactly like an eth_getTransactionByHash response + # (mainnet tx 0xafc5627648e9d6944f3087e4aeeb11f950de4b0e64ee0caccf7552ba03e01c7e) + tx_map = %{ + "type" => "0x4", + "chainId" => "0x1", + "nonce" => "0x9b81", + "to" => "0xbcb73594df46001f29143f6853c965684cdaf803", + "value" => "0x0", + "input" => "0x32fba9bb", + "gas" => "0x40164", + "maxFeePerGas" => "0x5f355f55", + "maxPriorityFeePerGas" => "0x5f355f55", + "accessList" => [], + "authorizationList" => [ + %{ + "chainId" => "0x1", + "address" => "0x69e6bd1c4082403fc7917a61f6216552fc1a541d", + "nonce" => "0x2", + "yParity" => "0x0", + "r" => "0x3c67c1f1203dd722e316796c37c4b828b98f5a27578627879f415763e2ac5afd", + "s" => "0x4c77aba7874855a80c7357fb13ae953cf9d819a615c00baaa15a2e165a0f08f7" + } + ] + } + + assert {:ok, %Transaction.Eip7702{} = transaction} = Transaction.from_rpc_map(tx_map) + + assert [%Ethers.Authorization.Signed{} = signed_auth] = transaction.authorization_list + + assert signed_auth.authorization == + Ethers.Authorization.new!( + chain_id: 1, + address: "0x69e6bd1C4082403Fc7917a61F6216552fC1a541D", + nonce: 2 + ) + + assert signed_auth.signature_y_parity == 0 + + # Round-trips back to the RPC representation + rpc_map = Transaction.to_rpc_map(transaction) + assert rpc_map.type == "0x4" + + assert rpc_map.authorizationList == [ + %{ + chainId: "0x1", + address: "0x69e6bd1C4082403Fc7917a61F6216552fC1a541D", + nonce: "0x2", + yParity: "0x0", + r: "0x3c67c1f1203dd722e316796c37c4b828b98f5a27578627879f415763e2ac5afd", + s: "0x4c77aba7874855a80c7357fb13ae953cf9d819a615c00baaa15a2e165a0f08f7" + } + ] + end + + test "returns error for missing authorization list in EIP-7702 transaction" do + tx_map = %{ + "type" => "0x4", + "chainId" => "0x1", + "nonce" => "0x0", + "to" => "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb5", + "value" => "0x0", + "gas" => "0x5208", + "maxFeePerGas" => "0x3b9aca00", + "maxPriorityFeePerGas" => "0x0" + } + + assert {:error, :empty_authorization_list} = Transaction.from_rpc_map(tx_map) + end end end diff --git a/test/ethers_test.exs b/test/ethers_test.exs index adc7c24..9b6329d 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -16,6 +16,7 @@ defmodule EthersTest do import Ethers.TestHelpers + alias Ethers.Signer alias Ethers.Transaction alias Ethers.Utils @@ -631,7 +632,8 @@ defmodule EthersTest do Ethers.Transaction.Legacy, Ethers.Transaction.Eip1559, Ethers.Transaction.Eip2930, - Ethers.Transaction.Eip4844 + Ethers.Transaction.Eip4844, + Ethers.Transaction.Eip7702 ] for type <- types do @@ -642,6 +644,7 @@ defmodule EthersTest do from: @from, type: type, to: "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + authorization_list: [authorization_fixture()], rpc_opts: [send_params_to_pid: self()] ) @@ -660,7 +663,8 @@ defmodule EthersTest do types = [ Ethers.Transaction.Legacy, Ethers.Transaction.Eip1559, - Ethers.Transaction.Eip2930 + Ethers.Transaction.Eip2930, + Ethers.Transaction.Eip7702 # Does not work with Anvil without sidecar # Ethers.Transaction.Eip4844 ] @@ -679,6 +683,7 @@ defmodule EthersTest do ) ], access_list: access_list_fixture(), + authorization_list: [authorization_fixture()], signer_opts: [ private_key: @from_private_key ] @@ -885,6 +890,164 @@ defmodule EthersTest do end end + describe "sign_authorization/2" do + # Anvil dev account #6 + @authority "0x976EA74026E726554dB657fA54763abd0C3a0aa9" + @authority_private_key "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e" + + @delegate "0x2222222222222222222222222222222222222222" + + test "signs an authorization with auto-fetched chain_id and nonce" do + {:ok, expected_nonce} = Ethers.get_transaction_count(@authority, block: "latest") + + assert {:ok, %Ethers.Authorization.Signed{} = signed} = + Ethers.sign_authorization(%{address: @delegate}, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_private_key] + ) + + assert signed.authorization.chain_id == 31_337 + assert signed.authorization.nonce == expected_nonce + assert signed.authorization.address == @delegate + + assert {:ok, recovered} = Ethers.Authorization.Signed.recover_authority(signed) + assert String.downcase(recovered) == String.downcase(@authority) + end + + test "executor: :self signs over the next nonce" do + {:ok, current_nonce} = Ethers.get_transaction_count(@authority, block: "latest") + + assert {:ok, %Ethers.Authorization.Signed{} = signed} = + Ethers.sign_authorization(%{address: @delegate}, + executor: :self, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_private_key] + ) + + assert signed.authorization.nonce == current_nonce + 1 + end + + test "explicitly given fields are never adjusted or fetched" do + assert {:ok, %Ethers.Authorization.Signed{} = signed} = + Ethers.sign_authorization(%{address: @delegate, chain_id: 1, nonce: 5}, + executor: :self, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_private_key] + ) + + assert signed.authorization.chain_id == 1 + assert signed.authorization.nonce == 5 + end + + test "accepts a ready Ethers.Authorization struct" do + authorization = Ethers.Authorization.new!(chain_id: 31_337, address: @delegate, nonce: 3) + + assert {:ok, %Ethers.Authorization.Signed{authorization: ^authorization}} = + Ethers.sign_authorization(authorization, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_private_key] + ) + end + + test "raises on invalid :executor option" do + assert_raise ArgumentError, ~r/invalid :executor option/, fn -> + Ethers.sign_authorization(%{address: @delegate}, executor: :other) + end + end + + test "returns :not_supported for signers without authorization support" do + assert {:error, :not_supported} = + Ethers.sign_authorization(%{address: @delegate, chain_id: 31_337, nonce: 0}, + signer: Ethers.Signer.JsonRPC + ) + end + end + + describe "EIP-7702 delegation" do + # Anvil dev account #7 + @authority_7702 "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" + @authority_7702_private_key "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356" + + @zero_address "0x0000000000000000000000000000000000000000" + + test "full lifecycle: sponsored delegation, delegated execution, self-sponsored clear" do + delegate = deploy(HelloWorldContract, from: @from) + + # The authority signs the authorization; @from sponsors the type-4 transaction and + # calls the freshly delegated EOA in the same transaction. + {:ok, signed_auth} = + Ethers.sign_authorization(%{address: delegate}, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_7702_private_key] + ) + + {:ok, tx_hash} = + HelloWorldContract.set_hello("hello 7702") + |> Ethers.send_transaction( + type: Ethers.Transaction.Eip7702, + to: @authority_7702, + from: @from, + authorization_list: [signed_auth], + signer: Ethers.Signer.Local, + signer_opts: [private_key: @from_private_key] + ) + + wait_for_transaction!(tx_hash) + + # The authority's account code is now the delegation designator 0xef0100 ++ delegate + assert {:ok, code} = + Ethereumex.HttpClient.eth_get_code(String.downcase(@authority_7702), "latest") + + assert code == "0xef0100" <> String.downcase(String.replace_prefix(delegate, "0x", "")) + + # Calls to the EOA now execute the delegate's code against the EOA's own storage + assert {:ok, "hello 7702"} = + Ethers.call(HelloWorldContract.say_hello(), to: @authority_7702) + + # Clear the delegation with a self-sponsored transaction (authority sends it itself, + # so the authorization must be signed over the next nonce via executor: :self) + {:ok, clear_auth} = + Ethers.sign_authorization(%{address: @zero_address}, + executor: :self, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_7702_private_key] + ) + + {:ok, clear_tx_hash} = + Ethers.send_transaction( + %{}, + type: Ethers.Transaction.Eip7702, + to: @authority_7702, + from: @authority_7702, + authorization_list: [clear_auth], + signer: Ethers.Signer.Local, + signer_opts: [private_key: @authority_7702_private_key] + ) + + wait_for_transaction!(clear_tx_hash) + + assert {:ok, "0x"} = + Ethereumex.HttpClient.eth_get_code(String.downcase(@authority_7702), "latest") + end + end + + defp authorization_fixture do + # Signed offline for Anvil's chain (31337) with a deliberately far-future nonce so the + # delegation never applies on the shared node — invalid authorizations do not + # invalidate the transaction, so this still exercises the full type-4 path. + {:ok, signed} = + Ethers.Authorization.new!( + chain_id: 31_337, + address: "0x2222222222222222222222222222222222222222", + nonce: 1_000_000 + ) + |> Signer.Local.sign_authorization( + private_key: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" + ) + + signed + end + defp access_list_fixture do [ [