diff --git a/CHANGELOG.md b/CHANGELOG.md index 727a0fd..aa8c228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ - Transaction auto-fill now prices EIP-1559 transactions with `Ethers.estimate_fees/1` instead of overpaying with a margin over `eth_gasPrice`, falling back to the legacy behaviour on RPC clients without `eth_feeHistory` support +- Add state override support to `Ethers.call/2` and `Ethers.estimate_gas/2` via the new + `:state_overrides` option: simulate calls against spoofed balances/nonces, injected + contract code or rewritten storage slots (see `Ethers.StateOverride`) +- Add `Ethers.create_access_list/2` (`eth_createAccessList`): generate the + [EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) access list a transaction would touch, + in a format that plugs directly back into the `:access_list` transaction override ## 0.7.0 (2026-07-20) diff --git a/lib/ethers.ex b/lib/ethers.ex index a4b0414..ed950ad 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -35,6 +35,7 @@ defmodule Ethers do - `{action_name_atom, data, overrides_keyword_list}`: Use this to override or add attributes to the action data. This is only accepted for these actions and will through error on others. - `:call`: data should be a Ethers.TxData struct and overrides are accepted. + - `:create_access_list`: data should be a Ethers.TxData struct and overrides are accepted. - `:estimate_gas`: data should be a Ethers.TxData struct or a map and overrides are accepted. - `:get_logs`: data should be a Ethers.EventFilter struct, an Ethers.CombinedEventFilter struct or an EventFilters module (to match all events of a contract) and overrides @@ -67,6 +68,7 @@ defmodule Ethers do alias Ethers.Event alias Ethers.EventFilter alias Ethers.ExecutionError + alias Ethers.StateOverride alias Ethers.Transaction alias Ethers.TxData alias Ethers.Types @@ -89,6 +91,7 @@ defmodule Ethers do @rpc_actions_map %{ call: :eth_call, chain_id: :eth_chain_id, + create_access_list: :eth_create_access_list, current_block_number: :eth_block_number, current_gas_price: :eth_gas_price, estimate_gas: :eth_estimate_gas, @@ -346,6 +349,9 @@ defmodule Ethers do - `:block`: The block number or block alias. 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.) + - `:state_overrides`: Execute the call against a modified chain state (spoofed balances, + injected contract code, rewritten storage slots, ...). See `Ethers.StateOverride` for the + accepted structure. Not supported in `Ethers.batch/2`. ## Return structure @@ -366,11 +372,13 @@ defmodule Ethers do def call(tx_data, overrides) do {opts, overrides} = Keyword.split(overrides, @option_keys) + {state_overrides, overrides} = Keyword.pop(overrides, :state_overrides) {rpc_client, rpc_opts} = get_rpc_client(opts) - with {:ok, tx_params, block} <- pre_process(tx_data, overrides, :call, opts) do - rpc_client.eth_call(tx_params, block, rpc_opts) + with {:ok, tx_params, block} <- pre_process(tx_data, overrides, :call, opts), + {:ok, state_overrides} <- encode_state_overrides(state_overrides) do + eth_call(rpc_client, tx_params, block, state_overrides, rpc_opts) |> post_process(tx_data, :call) end end @@ -779,8 +787,13 @@ defmodule Ethers do ## Overrides and Options - `:to`: Indicates recipient address. (Contract address in this case) + - `:block`: The block number or block alias. Only sent to the RPC server when + `:state_overrides` are given. 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.) + - `:state_overrides`: Estimate against a modified chain state (spoofed balances, injected + contract code, rewritten storage slots, ...). See `Ethers.StateOverride` for the accepted + structure. Not supported in `Ethers.batch/2`. ```elixir Ethers.Contract.ERC20.transfer("0xff0...ea2", 1000) |> Ethers.estimate_gas(to: "0xa0b...ef6") @@ -790,11 +803,20 @@ defmodule Ethers do @spec estimate_gas(map(), Keyword.t()) :: {:ok, non_neg_integer()} | {:error, term()} def estimate_gas(tx_data, overrides \\ []) do {opts, overrides} = Keyword.split(overrides, @option_keys) + {state_overrides, overrides} = Keyword.pop(overrides, :state_overrides) + {block, overrides} = Keyword.pop(overrides, :block, "latest") {rpc_client, rpc_opts} = get_rpc_client(opts) - with {:ok, tx_params} <- pre_process(tx_data, overrides, :estimate_gas, opts) do - rpc_client.eth_estimate_gas(tx_params, rpc_opts) + with {:ok, tx_params} <- pre_process(tx_data, overrides, :estimate_gas, opts), + {:ok, state_overrides} <- encode_state_overrides(state_overrides) do + eth_estimate_gas( + rpc_client, + tx_params, + ensure_block_tag_hex(block), + state_overrides, + rpc_opts + ) |> post_process(tx_data, :estimate_gas) end end @@ -810,6 +832,66 @@ defmodule Ethers do end end + @doc """ + Makes an eth_createAccessList rpc call with the given parameters and overrides. + + Simulates the transaction and returns the list of addresses and storage slots it accesses, + in the format transaction functions accept as the `:access_list` override — so the result + can be fed straight back into `Ethers.send_transaction/2` (with an EIP-2930 or later + transaction type) to reduce gas usage of the transaction. + + Also works with `Ethers.batch/2` as `{:create_access_list, tx_data, overrides}`. + + ## Overrides and Options + + - `:to`: Indicates recipient address. (Contract address in this case) + - `:block`: The block number or block alias. 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, result}` where result is a map with these keys: + + - `:access_list`: The generated access list. + - `:gas_used`: Gas consumed by the simulated transaction (with the access list applied). + - `:error`: Only present when the simulated transaction reverts; holds the node's error + message. The access list is still generated in that case. + + ## Examples + + ```elixir + MyContract.some_function() |> Ethers.create_access_list(from: address) + {:ok, %{access_list: [[<<_::160>>, [<<_::256>>, ...]], ...], gas_used: 25000}} + ``` + """ + @spec create_access_list(map() | TxData.t(), Keyword.t()) :: {:ok, map()} | {:error, term()} + def create_access_list(tx_data, overrides \\ []) do + {opts, overrides} = Keyword.split(overrides, @option_keys) + + {rpc_client, rpc_opts} = get_rpc_client(opts) + + with {:ok, tx_params, block} <- pre_process(tx_data, overrides, :create_access_list, opts) do + if rpc_callback_supported?(rpc_client, :eth_create_access_list, 3) do + rpc_client.eth_create_access_list(tx_params, block, rpc_opts) + else + {:error, :not_supported} + end + |> post_process(tx_data, :create_access_list) + end + end + + @doc """ + Same as `Ethers.create_access_list/2` but raises on error. + """ + @spec create_access_list!(map() | TxData.t(), Keyword.t()) :: map() | no_return() + def create_access_list!(tx_data, overrides \\ []) do + case create_access_list(tx_data, overrides) do + {:ok, result} -> result + {:error, reason} -> raise ExecutionError, reason + end + end + @doc """ Returns the current max priority fee per gas from the RPC API """ @@ -1101,20 +1183,21 @@ defmodule Ethers do @spec get_rpc_client(Keyword.t()) :: {atom(), Keyword.t()} defdelegate get_rpc_client(opts), to: Ethers.RpcClient - defp pre_process(tx_data, overrides, :call = _action, _opts) do - {block, overrides} = Keyword.pop(overrides, :block, "latest") - - block = - case block do - number when is_integer(number) -> Utils.integer_to_hex(number) - v -> v - end + defp pre_process(tx_data, overrides, action, _opts) + when action in [:call, :create_access_list] do + # :state_overrides is popped before pre_process in the direct paths, so its presence + # here means the action came through a batch request where it is not supported. + if Keyword.has_key?(overrides, :state_overrides) do + {:error, :state_overrides_not_supported_in_batch} + else + {block, overrides} = Keyword.pop(overrides, :block, "latest") - tx_params = TxData.to_map(tx_data, overrides) + tx_params = TxData.to_map(tx_data, overrides) - case check_params(tx_params, :call) do - :ok -> {:ok, Transaction.to_rpc_map(tx_params), block} - err -> err + case check_params(tx_params, action) do + :ok -> {:ok, Transaction.to_rpc_map(tx_params), ensure_block_tag_hex(block)} + err -> err + end end end @@ -1169,10 +1252,14 @@ defmodule Ethers do end defp pre_process(tx_data, overrides, :estimate_gas = action, _opts) do - tx_params = TxData.to_map(tx_data, overrides) + if Keyword.has_key?(overrides, :state_overrides) do + {:error, :state_overrides_not_supported_in_batch} + else + tx_params = TxData.to_map(tx_data, overrides) - with :ok <- check_params(tx_params, action) do - {:ok, Transaction.to_rpc_map(tx_params)} + with :ok <- check_params(tx_params, action) do + {:ok, Transaction.to_rpc_map(tx_params)} + end end end @@ -1298,6 +1385,18 @@ defmodule Ethers do }} end + defp post_process({:ok, %{"accessList" => access_list} = resp}, _tx_data, :create_access_list) do + result = %{ + access_list: decode_rpc_access_list(access_list), + gas_used: resp |> Map.get("gasUsed") |> then(&(&1 && Utils.hex_to_integer!(&1))) + } + + case Map.get(resp, "error") do + nil -> {:ok, result} + error -> {:ok, Map.put(result, :error, error)} + end + end + defp post_process({:ok, result}, _tx_data, _action), do: {:ok, result} @@ -1324,6 +1423,33 @@ defmodule Ethers do defp post_process({:error, cause}, _tx_data, _action), do: {:error, cause} + defp eth_call(rpc_client, tx_params, block, nil = _state_overrides, rpc_opts) do + rpc_client.eth_call(tx_params, block, rpc_opts) + end + + defp eth_call(rpc_client, tx_params, block, state_overrides, rpc_opts) do + if rpc_callback_supported?(rpc_client, :eth_call, 4) do + rpc_client.eth_call(tx_params, block, state_overrides, rpc_opts) + else + {:error, :state_overrides_not_supported} + end + end + + defp eth_estimate_gas(rpc_client, tx_params, _block, nil = _state_overrides, rpc_opts) do + rpc_client.eth_estimate_gas(tx_params, rpc_opts) + end + + defp eth_estimate_gas(rpc_client, tx_params, block, state_overrides, rpc_opts) do + if rpc_callback_supported?(rpc_client, :eth_estimate_gas, 4) do + rpc_client.eth_estimate_gas(tx_params, block, state_overrides, rpc_opts) + else + {:error, :state_overrides_not_supported} + end + end + + defp encode_state_overrides(nil), do: {:ok, nil} + defp encode_state_overrides(state_overrides), do: StateOverride.to_rpc_map(state_overrides) + defp fee_percentile(speed) when is_map_key(@fee_estimation_percentiles, speed), do: {:ok, Map.fetch!(@fee_estimation_percentiles, speed)} @@ -1384,6 +1510,20 @@ defmodule Ethers do Code.ensure_loaded?(rpc_client) and function_exported?(rpc_client, callback, arity) end + defp decode_rpc_access_list(access_list) when is_list(access_list) do + Enum.map(access_list, fn entry -> + [ + entry |> Map.get("address") |> Utils.hex_decode!(), + entry |> Map.get("storageKeys", []) |> Enum.map(&Utils.hex_decode!/1) + ] + end) + end + + defp decode_rpc_access_list(_access_list), do: [] + + defp ensure_block_tag_hex(number) when is_integer(number), do: Utils.integer_to_hex(number) + defp ensure_block_tag_hex(tag), do: tag + defp ensure_hex_value(params, key) do case Map.get(params, key) do v when is_integer(v) -> %{params | key => Utils.integer_to_hex(v)} diff --git a/lib/ethers/rpc_client/adapter.ex b/lib/ethers/rpc_client/adapter.ex index ce6a523..d9c63e3 100644 --- a/lib/ethers/rpc_client/adapter.ex +++ b/lib/ethers/rpc_client/adapter.ex @@ -10,10 +10,18 @@ defmodule Ethers.RpcClient.Adapter do @callback eth_call(map(), binary(), keyword()) :: {:ok, binary()} | error() + @callback eth_call(map(), binary(), state_overrides :: map(), keyword()) :: + {:ok, binary()} | error() + @callback eth_chain_id(keyword()) :: {:ok, binary()} | error() + @callback eth_create_access_list(map(), binary(), keyword()) :: {:ok, map()} | error() + @callback eth_estimate_gas(map(), keyword()) :: {:ok, binary()} | error() + @callback eth_estimate_gas(map(), binary(), state_overrides :: map(), keyword()) :: + {:ok, binary()} | error() + @callback eth_fee_history( block_count :: binary(), newest_block :: binary(), @@ -44,8 +52,10 @@ defmodule Ethers.RpcClient.Adapter do @callback eth_send_raw_transaction(binary(), keyword()) :: {:ok, binary()} | error() - # Optional to keep custom RPC client adapters backwards compatible. Ethers checks - # availability with `function_exported?/3` and falls back to the legacy gas price - # based fee estimation when the adapter does not implement it. - @optional_callbacks eth_fee_history: 4 + # New callbacks are optional to keep custom RPC client adapters backwards compatible. + # Ethers checks their availability with `function_exported?/3` before use. + @optional_callbacks eth_call: 4, + eth_create_access_list: 3, + eth_estimate_gas: 4, + eth_fee_history: 4 end diff --git a/lib/ethers/rpc_client/ethereumex_http_client.ex b/lib/ethers/rpc_client/ethereumex_http_client.ex index 995c49e..6083ea8 100644 --- a/lib/ethers/rpc_client/ethereumex_http_client.ex +++ b/lib/ethers/rpc_client/ethereumex_http_client.ex @@ -5,9 +5,18 @@ defmodule Ethers.RpcClient.EthereumexHttpClient do @behaviour Ethers.RpcClient.Adapter - @exclude_delegation [:eth_get_logs] + # Callbacks implemented manually below instead of being delegated verbatim, either + # because Ethereumex does not expose the RPC method or because it needs param mapping. + @manual_implementations [ + batch_request: 2, + eth_call: 4, + eth_create_access_list: 3, + eth_estimate_gas: 4, + eth_get_logs: 2 + ] - for {func, arity} <- Adapter.behaviour_info(:callbacks), func not in @exclude_delegation do + for {func, arity} <- Adapter.behaviour_info(:callbacks), + {func, arity} not in @manual_implementations do args = Macro.generate_arguments(arity - 1, __MODULE__) @impl true @@ -16,6 +25,38 @@ defmodule Ethers.RpcClient.EthereumexHttpClient do end end + @impl true + def batch_request(methods, opts \\ []) do + # Build the batch from RPC method names through Ethereumex's generic request/3 + # instead of Ethereumex.HttpClient.batch_request/2 (which dispatches to named + # functions), so every method this adapter supports is also batchable + methods + |> Enum.with_index(1) + |> Enum.map(fn {{method, params}, id} -> + method + |> rpc_method_name() + |> Ethereumex.HttpClient.request(params, batch: true) + |> Map.put("id", id) + end) + |> Ethereumex.Config.json_module().encode!() + |> Ethereumex.HttpClient.post_request(opts) + end + + @impl true + def eth_call(params, block, state_overrides, opts) do + Ethereumex.HttpClient.request("eth_call", [params, block, state_overrides], opts) + end + + @impl true + def eth_create_access_list(params, block, opts \\ []) do + Ethereumex.HttpClient.request("eth_createAccessList", [params, block], opts) + end + + @impl true + def eth_estimate_gas(params, block, state_overrides, opts) do + Ethereumex.HttpClient.request("eth_estimateGas", [params, block, state_overrides], opts) + end + @impl true def eth_get_logs(params, opts \\ []) do params @@ -24,6 +65,18 @@ defmodule Ethers.RpcClient.EthereumexHttpClient do |> Ethereumex.HttpClient.eth_get_logs(opts) end + # Converts a snake_case method atom to the camelCase JSON-RPC method name, keeping + # the namespace prefix: :eth_create_access_list -> "eth_createAccessList" + defp rpc_method_name(method) when is_atom(method) do + case method |> Atom.to_string() |> String.split("_") do + [namespace] -> + namespace + + [namespace, part | parts] -> + "#{namespace}_#{part}#{Enum.map_join(parts, &String.capitalize/1)}" + end + end + defp replace_key(map, ethers_key, ethereumex_key) do case Map.fetch(map, ethers_key) do {:ok, value} -> diff --git a/lib/ethers/state_override.ex b/lib/ethers/state_override.ex new file mode 100644 index 0000000..ee3548e --- /dev/null +++ b/lib/ethers/state_override.ex @@ -0,0 +1,157 @@ +defmodule Ethers.StateOverride do + @moduledoc """ + State overrides for simulation RPC calls (`eth_call` and `eth_estimateGas`). + + State overrides let a call run against a modified view of the chain state without + sending any transaction: spoof an account's balance or nonce, replace the code at an + address (e.g. run a contract that is not deployed), or rewrite individual storage + slots. They are supported by all major execution clients (geth, reth, anvil, ...). + + Pass them to `Ethers.call/2`, `Ethers.estimate_gas/2` (and by extension any generated + contract function piped into those) with the `:state_overrides` option: + + ```elixir + MyToken.transfer(receiver, 1000) + |> Ethers.call( + from: whale, + state_overrides: %{ + whale => %{balance: Ethers.Utils.to_wei(100)}, + token_address => %{state_diff: %{balance_slot => balance_value}} + } + ) + ``` + + ## Structure + + A state override set is a map of `address => account override`. Following Ethers + conventions, every value has exactly one accepted representation — native types, never + hex strings: + + - `:balance` - fake balance to set for the account (`non_neg_integer`) + - `:nonce` - fake nonce to set for the account (`non_neg_integer`) + - `:code` - fake EVM bytecode to inject into the account (raw `binary`, **not** hex + encoded — hex decode first if you have `"0x..."` bytecode e.g. from `eth_getCode`) + - `:state` - fake key-value mapping to override **all** slots in the account storage + - `:state_diff` - fake key-value mapping to override **individual** slots in the + account storage (all other slots keep their on-chain values) + + `:state` and `:state_diff` are mutually exclusive per account. Their keys (storage + slots) and values (storage words) accept a `non_neg_integer` or a raw 32-byte binary + (e.g. a keccak-derived mapping slot), and are encoded as 32-byte hex words. + + Addresses are hex strings (`"0x..."`), like everywhere else in Ethers. + """ + + alias Ethers.Types + alias Ethers.Utils + + @typedoc """ + A storage slot or storage value: a non-negative integer or a raw 32-byte binary. + """ + @type storage_word :: non_neg_integer() | <<_::256>> + + @typedoc """ + Overrides for a single account. See the module documentation for the accepted keys. + """ + @type account_override :: %{ + optional(:balance) => non_neg_integer(), + optional(:nonce) => non_neg_integer(), + optional(:code) => binary(), + optional(:state) => %{storage_word() => storage_word()}, + optional(:state_diff) => %{storage_word() => storage_word()} + } + + @typedoc "A state override set: a map of account address to account override." + @type t :: %{Types.t_address() => account_override()} + + @doc """ + Encodes a state override set into the JSON-RPC representation. + + Returns `{:ok, rpc_map}` with all quantities, code and storage words hex-encoded, + or `{:error, reason}` if the input is not a valid state override set. + + ## Examples + + iex> Ethers.StateOverride.to_rpc_map(%{ + ...> "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" => %{balance: 1000, nonce: 3} + ...> }) + {:ok, %{"0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" => %{balance: "0x3E8", nonce: "0x3"}}} + """ + @spec to_rpc_map(t()) :: {:ok, map()} | {:error, term()} + def to_rpc_map(state_overrides) when is_map(state_overrides) do + Enum.reduce_while(state_overrides, {:ok, %{}}, fn {address, account_override}, {:ok, acc} -> + with {:ok, address} <- validate_address(address), + {:ok, account_override} <- encode_account_override(account_override) do + {:cont, {:ok, Map.put(acc, address, account_override)}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + def to_rpc_map(_state_overrides), do: {:error, :invalid_state_overrides} + + defp validate_address(address) do + case Utils.decode_address(address) do + {:ok, _bin} -> {:ok, address} + {:error, reason} -> {:error, {reason, address}} + end + end + + defp encode_account_override(account_override) when is_map(account_override) do + if Map.has_key?(account_override, :state) and Map.has_key?(account_override, :state_diff) do + {:error, :state_and_state_diff_exclusive} + else + encode_account_override_values(account_override) + end + end + + defp encode_account_override(account_override), + do: {:error, {:invalid_account_override, account_override}} + + defp encode_account_override_values(account_override) do + Enum.reduce_while(account_override, {:ok, %{}}, fn {key, value}, {:ok, acc} -> + case encode_account_override_value(key, value) do + {:ok, key, value} -> {:cont, {:ok, Map.put(acc, key, value)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp encode_account_override_value(key, quantity) + when key in [:balance, :nonce] and is_integer(quantity) and quantity >= 0, + do: {:ok, key, Utils.integer_to_hex(quantity)} + + defp encode_account_override_value(:code, code) when is_binary(code), + do: {:ok, :code, Utils.hex_encode(code)} + + defp encode_account_override_value(key, storage_map) + when key in [:state, :state_diff] and is_map(storage_map) do + rpc_key = if key == :state, do: :state, else: :stateDiff + + Enum.reduce_while(storage_map, {:ok, %{}}, fn {slot, value}, {:ok, acc} -> + with {:ok, slot} <- encode_storage_word(slot), + {:ok, value} <- encode_storage_word(value) do + {:cont, {:ok, Map.put(acc, slot, value)}} + else + {:error, reason} -> {:halt, {:error, {:invalid_account_override, {key, reason}}}} + end + end) + |> case do + {:ok, encoded} -> {:ok, rpc_key, encoded} + {:error, reason} -> {:error, reason} + end + end + + defp encode_account_override_value(key, value), + do: {:error, {:invalid_account_override, {key, value}}} + + @max_word 2 ** 256 - 1 + + defp encode_storage_word(word) when is_integer(word) and word >= 0 and word <= @max_word, + do: {:ok, Utils.hex_encode(<>)} + + defp encode_storage_word(<<_::binary-32>> = word), do: {:ok, Utils.hex_encode(word)} + + defp encode_storage_word(word), do: {:error, {:invalid_storage_word, word}} +end diff --git a/test/ethers/state_override_test.exs b/test/ethers/state_override_test.exs new file mode 100644 index 0000000..0fbc076 --- /dev/null +++ b/test/ethers/state_override_test.exs @@ -0,0 +1,238 @@ +defmodule Ethers.Contract.Test.StateOverrideCounterContract do + @moduledoc false + use Ethers.Contract, abi_file: "tmp/counter_abi.json" +end + +defmodule Ethers.StateOverrideTest do + use ExUnit.Case + doctest Ethers.StateOverride + + import Ethers.TestHelpers + + alias Ethers.Contract.Test.StateOverrideCounterContract, as: CounterContract + alias Ethers.StateOverride + alias Ethers.Utils + + @from "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + # An address nothing is deployed to and nobody funds + @empty_address "0x1111111111111111111111111111111111111111" + + describe "to_rpc_map/1" do + test "encodes quantities, code and storage words" do + code = Ethers.Utils.hex_decode!("0x60016000f3") + + assert {:ok, + %{ + @empty_address => %{ + balance: "0xDE0B6B3A7640000", + nonce: "0x7", + code: "0x60016000f3", + stateDiff: state_diff + } + }} = + StateOverride.to_rpc_map(%{ + @empty_address => %{ + balance: 1_000_000_000_000_000_000, + nonce: 7, + code: code, + state_diff: %{0 => 42} + } + }) + + assert state_diff == %{ + ("0x" <> String.duplicate("0", 64)) => "0x" <> String.duplicate("0", 62) <> "2a" + } + end + + test "treats code as raw bytes even when it starts with the characters 0x" do + # A raw binary may legitimately start with the bytes "0x" - it must never be + # mistaken for a hex encoded string + code = "0xab" + + assert {:ok, %{@empty_address => %{code: "0x30786162"}}} == + StateOverride.to_rpc_map(%{@empty_address => %{code: code}}) + end + + test "encodes full state replacement with integer and 32-byte binary words" do + slot_bin = <<0::248, 1>> + + assert {:ok, %{@empty_address => %{state: state}}} = + StateOverride.to_rpc_map(%{@empty_address => %{state: %{slot_bin => 255}}}) + + assert state == %{ + ("0x" <> String.duplicate("0", 63) <> "1") => + "0x" <> String.duplicate("0", 62) <> "ff" + } + end + + test "rejects invalid addresses including raw binary addresses" do + assert {:error, {:invalid_address, "0xinvalid"}} = + StateOverride.to_rpc_map(%{"0xinvalid" => %{balance: 1}}) + + assert {:error, {:invalid_address, :bad}} = StateOverride.to_rpc_map(%{bad: %{balance: 1}}) + + # Only one representation is accepted for addresses: hex strings + address_bin = Utils.hex_decode!(@empty_address) + + assert {:error, {:invalid_address, ^address_bin}} = + StateOverride.to_rpc_map(%{address_bin => %{balance: 1}}) + end + + test "rejects hex string quantities and storage words" do + assert {:error, {:invalid_account_override, {:balance, "0x1234"}}} = + StateOverride.to_rpc_map(%{@empty_address => %{balance: "0x1234"}}) + + assert {:error, {:invalid_account_override, {:state, {:invalid_storage_word, "0x1"}}}} = + StateOverride.to_rpc_map(%{@empty_address => %{state: %{"0x1" => 255}}}) + + assert {:error, {:invalid_account_override, {:state_diff, {:invalid_storage_word, "0xff"}}}} = + StateOverride.to_rpc_map(%{@empty_address => %{state_diff: %{0 => "0xff"}}}) + end + + test "rejects unknown account override keys and invalid values" do + assert {:error, {:invalid_account_override, {:storage, _}}} = + StateOverride.to_rpc_map(%{@empty_address => %{storage: %{}}}) + + assert {:error, {:invalid_account_override, {:balance, -1}}} = + StateOverride.to_rpc_map(%{@empty_address => %{balance: -1}}) + + assert {:error, {:invalid_account_override, {:state_diff, {:invalid_storage_word, _}}}} = + StateOverride.to_rpc_map(%{@empty_address => %{state_diff: %{"bad" => 1}}}) + end + + test "rejects mixing state and state_diff for the same account" do + assert {:error, :state_and_state_diff_exclusive} = + StateOverride.to_rpc_map(%{ + @empty_address => %{state: %{0 => 1}, state_diff: %{0 => 1}} + }) + end + + test "rejects non-map inputs" do + assert {:error, :invalid_state_overrides} = StateOverride.to_rpc_map([]) + + assert {:error, {:invalid_account_override, nil}} = + StateOverride.to_rpc_map(%{@empty_address => nil}) + end + end + + describe "Ethers.call/2 with state overrides" do + setup :deploy_counter_contract + + test "state_diff overrides a single storage slot", %{address: address} do + assert {:ok, 100} = CounterContract.get() |> Ethers.call(to: address) + + assert {:ok, 424_242} = + CounterContract.get() + |> Ethers.call( + to: address, + state_overrides: %{address => %{state_diff: %{0 => 424_242}}} + ) + + # The override never touches the actual chain state + assert {:ok, 100} = CounterContract.get() |> Ethers.call(to: address) + end + + test "code override runs a contract at an address with no code", %{address: address} do + {:ok, code_hex} = Ethereumex.HttpClient.eth_get_code(String.downcase(address), "latest") + code = Utils.hex_decode!(code_hex) + + assert {:error, _} = CounterContract.get() |> Ethers.call(to: @empty_address) + + assert {:ok, 0} = + CounterContract.get() + |> Ethers.call( + to: @empty_address, + state_overrides: %{@empty_address => %{code: code}} + ) + + assert {:ok, 5} = + CounterContract.get() + |> Ethers.call( + to: @empty_address, + state_overrides: %{@empty_address => %{code: code, state_diff: %{0 => 5}}} + ) + end + + test "returns encoding errors without hitting the RPC", %{address: address} do + assert {:error, {:invalid_address, "0xinvalid"}} = + CounterContract.get() + |> Ethers.call(to: address, state_overrides: %{"0xinvalid" => %{balance: 1}}) + end + + test "returns error when the RPC client does not support state overrides", %{ + address: address + } do + assert {:error, :state_overrides_not_supported} = + CounterContract.get() + |> Ethers.call( + to: address, + rpc_client: Ethers.TestRPCModule, + state_overrides: %{address => %{state_diff: %{0 => 1}}} + ) + end + + test "is rejected in batch requests", %{address: address} do + assert {:error, :state_overrides_not_supported_in_batch} = + Ethers.batch([ + {:call, CounterContract.get(), + [to: address, state_overrides: %{address => %{state_diff: %{0 => 1}}}]} + ]) + end + end + + describe "Ethers.estimate_gas/2 with state overrides" do + setup :deploy_counter_contract + + test "code override changes the gas estimate", %{address: address} do + {:ok, code_hex} = Ethereumex.HttpClient.eth_get_code(String.downcase(address), "latest") + code = Utils.hex_decode!(code_hex) + + set_call = CounterContract.set(842) + + # Without the override the target has no code, so this is priced as a plain transfer + assert {:ok, base_gas} = Ethers.estimate_gas(set_call, to: @empty_address, from: @from) + + assert {:ok, override_gas} = + Ethers.estimate_gas(set_call, + to: @empty_address, + from: @from, + state_overrides: %{@empty_address => %{code: code}} + ) + + # With the counter code injected the call executes an SSTORE and costs more + assert override_gas > base_gas + end + + test "balance override funds an empty sender" do + params = %{from: @empty_address, to: @from, value: Utils.to_wei(1)} + + assert {:ok, 21_000} = + Ethers.estimate_gas(params, + state_overrides: %{@empty_address => %{balance: Utils.to_wei(10)}} + ) + end + + test "returns error when the RPC client does not support state overrides", %{ + address: address + } do + assert {:error, :state_overrides_not_supported} = + CounterContract.set(1) + |> Ethers.estimate_gas( + to: address, + from: @from, + rpc_client: Ethers.TestRPCModule, + state_overrides: %{address => %{state_diff: %{0 => 1}}} + ) + end + end + + defp deploy_counter_contract(_ctx) do + address = + deploy(CounterContract, + encoded_constructor: CounterContract.constructor(100), + from: @from + ) + + [address: address] + end +end diff --git a/test/ethers_test.exs b/test/ethers_test.exs index 9b6329d..bc9303e 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -852,6 +852,99 @@ defmodule EthersTest do end end + describe "create_access_list/2" do + test "returns the touched storage slots and gas used" do + address = deploy(HelloWorldContract, from: @from) + address_bin = Utils.hex_decode!(address) + + assert {:ok, %{access_list: access_list, gas_used: gas_used}} = + HelloWorldContract.set_hello("access list") + |> Ethers.create_access_list(to: address, from: @from) + + assert is_integer(gas_used) and gas_used > 21_000 + + assert [[^address_bin, storage_keys]] = access_list + assert Enum.all?(storage_keys, &match?(<<_::256>>, &1)) + + # The result plugs directly into an EIP-2930 transaction + assert {:ok, tx_hash} = + HelloWorldContract.set_hello("with access list") + |> Ethers.send_transaction( + to: address, + from: @from, + type: Ethers.Transaction.Eip2930, + access_list: access_list, + signer: Ethers.Signer.Local, + signer_opts: [private_key: @from_private_key] + ) + + wait_for_transaction!(tx_hash) + + assert {:ok, "with access list"} = + HelloWorldContract.say_hello() |> Ethers.call(to: address) + end + + test "returns the node error when the simulated transaction reverts" do + address = deploy(HelloWorldContract, from: @from) + + # Anvil rejects reverting calls at the RPC level. Geth instead reports the revert + # in the result's "error" field, which post processing preserves under :error. + assert {:error, %{"message" => message}} = + Ethers.create_access_list(%{data: "0xffffffff", value: 1}, + to: address, + from: @from + ) + + assert message =~ "revert" + end + + test "works in batch requests" do + address = deploy(HelloWorldContract, from: @from) + + assert {:ok, + [ + {:ok, %{access_list: [_ | _], gas_used: gas_used}}, + {:ok, block_number} + ]} = + Ethers.batch([ + {:create_access_list, HelloWorldContract.set_hello("batch access list"), + [to: address, from: @from]}, + :current_block_number + ]) + + assert is_integer(gas_used) + assert is_integer(block_number) + end + + test "returns error without to address" do + assert {:error, :no_to_address} = + HelloWorldContract.set_hello("access list") + |> Ethers.create_access_list(from: @from) + end + + test "returns error when the RPC client does not support it" do + assert {:error, :not_supported} = + HelloWorldContract.set_hello("access list") + |> Ethers.create_access_list( + to: @to, + from: @from, + rpc_client: Ethers.TestRPCModule + ) + end + + test "bang version returns unwrapped value and raises on error" do + address = deploy(HelloWorldContract, from: @from) + + assert %{access_list: _, gas_used: _} = + HelloWorldContract.set_hello("access list") + |> Ethers.create_access_list!(to: address, from: @from) + + assert_raise ExecutionError, fn -> + HelloWorldContract.set_hello("access list") |> Ethers.create_access_list!(from: @from) + end + end + end + describe "deprecated send/2 and send!/2 still work" do test "send/2 still works" do address = deploy(HelloWorldContract, from: @from)