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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
### Enhancements

- Add [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) set-code transaction support (type 4)
- Add `Ethers.fee_history/4` (`eth_feeHistory`) with decoded integer quantities, also usable
in `Ethers.batch/2` via `{:fee_history, [block_count, newest_block, reward_percentiles]}`
- Add `Ethers.estimate_fees/1`: estimate EIP-1559 `max_fee_per_gas` and
`max_priority_fee_per_gas` from recent blocks' priority fees (`:slow`/`:standard`/`:fast`
speeds or a raw percentile) with `max_fee_per_gas = 2 * next_base_fee + priority_fee`
- 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

## 0.7.0 (2026-07-20)

Expand Down
220 changes: 218 additions & 2 deletions lib/ethers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ defmodule Ethers do
current_block_number: :eth_block_number,
current_gas_price: :eth_gas_price,
estimate_gas: :eth_estimate_gas,
fee_history: :eth_fee_history,
gas_price: :eth_gas_price,
get_logs: :eth_get_logs,
get_transaction_count: :eth_get_transaction_count,
Expand All @@ -104,6 +105,12 @@ defmodule Ethers do
}
@send_transaction_actions [:send_transaction, :send]

# Percentiles the priority fees of recent blocks are sampled at per fee estimation speed
@fee_estimation_percentiles %{slow: 25, standard: 50, fast: 75}
@fee_estimation_block_count 10

@block_tags ["latest", "earliest", "pending", "safe", "finalized"]

@type t_batch_request :: atom() | {atom, term()} | {atom, term(), Keyword.t()}

defguardp valid_result(bin) when bin != "0x"
Expand Down Expand Up @@ -396,8 +403,12 @@ defmodule Ethers do
- `:chain_id`: Chain id for the transaction (defaults to chain id from RPC server).
- `:gas_price`: (legacy only) max price willing to pay for each gas.
- `:gas`: Gas limit for execution of this transaction.
- `:max_fee_per_gas`: (EIP-1559 only) max fee per gas (defaults to 120% current gas price estimate).
- `:max_priority_fee_per_gas`: (EIP-1559 only) max priority fee per gas or validator tip. (defaults to zero)
- `:max_fee_per_gas`: (EIP-1559 only) max fee per gas (estimated with `Ethers.estimate_fees/1`
from recent blocks, falling back to 120% of the current gas price on RPC clients without
`eth_feeHistory` support).
- `:max_priority_fee_per_gas`: (EIP-1559 only) max priority fee per gas or validator tip.
(estimated with `Ethers.estimate_fees/1` from recent blocks, falling back to
`eth_maxPriorityFeePerGas`)
- `:nonce`: Nonce of the transaction. (defaults to number of transactions of from address)
- `: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.)
Expand Down Expand Up @@ -845,6 +856,118 @@ defmodule Ethers do
end
end

@doc """
Returns the fee history from the RPC API (`eth_feeHistory`).

Also works with `Ethers.batch/2` as `{:fee_history, [block_count, newest_block, reward_percentiles]}`.

## Parameters
- `block_count`: Number of blocks to look back. (positive integer)
- `newest_block`: Highest block of the requested range — a block number (integer) or one
of the block tags `"latest"`, `"earliest"`, `"pending"`, `"safe"` or `"finalized"`.
Hex encoded block numbers are not accepted. (default `"latest"`)
- `reward_percentiles`: List of percentiles (0-100) to sample the priority fees of the
transactions in each block at. When empty, no `:reward` data is returned. (default `[]`)
- `opts`: RPC related options.

## Returns

`{:ok, fee_history}` where fee_history is a map with these keys (quantities decoded to
integers):

- `:oldest_block`: Number of the first block in the range.
- `:base_fee_per_gas`: Base fee per gas for each block in the range, **plus one extra
entry: the expected base fee of the next block**.
- `:gas_used_ratio`: Gas used ratio (0..1) of each block in the range.
- `:reward`: For each block, the transaction priority fees at each requested percentile.
`nil` (or sometimes `[]`, depending on the node) when `reward_percentiles` is empty.
- `:base_fee_per_blob_gas` / `:blob_gas_used_ratio`: Same as their gas counterparts, for
blob gas (EIP-4844). `nil` when the node does not report them.

Returns `{:error, :not_supported}` if the configured RPC client does not implement the
optional `eth_fee_history/4` callback.
"""
@spec fee_history(pos_integer(), binary() | non_neg_integer(), [number()], Keyword.t()) ::
{:ok, map()} | {:error, term()}
def fee_history(block_count, newest_block \\ "latest", reward_percentiles \\ [], opts \\ []) do
{rpc_client, rpc_opts} = get_rpc_client(opts)

with {:ok, [block_count, newest_block, reward_percentiles]} <-
pre_process([block_count, newest_block, reward_percentiles], [], :fee_history, opts) do
if rpc_callback_supported?(rpc_client, :eth_fee_history, 4) do
rpc_client.eth_fee_history(block_count, newest_block, reward_percentiles, rpc_opts)
else
{:error, :not_supported}
end
|> post_process(nil, :fee_history)
end
end

@doc """
Same as `Ethers.fee_history/4` but raises on error.
"""
@spec fee_history!(pos_integer(), binary() | non_neg_integer(), [number()], Keyword.t()) ::
map() | no_return()
def fee_history!(block_count, newest_block \\ "latest", reward_percentiles \\ [], opts \\ []) do
case fee_history(block_count, newest_block, reward_percentiles, opts) do
{:ok, fee_history} -> fee_history
{:error, reason} -> raise ExecutionError, reason
end
end

@doc """
Estimates EIP-1559 fees (`max_fee_per_gas` and `max_priority_fee_per_gas`) based on
recent blocks using `Ethers.fee_history/4`.

The priority fee is the median of the recent blocks' priority fees sampled at the
percentile selected by `:speed`, and the max fee adds headroom for base fee growth:

max_priority_fee_per_gas = median(reward at percentile over recent blocks)
max_fee_per_gas = 2 * next_block_base_fee + max_priority_fee_per_gas

This is also what transaction auto-fill uses to price EIP-1559 transactions when the fees
are not explicitly provided (with a fallback to the legacy gas price based estimation on
RPC clients without `eth_feeHistory` support).

## Options
- `:speed`: `:slow`, `:standard` or `:fast` — samples priority fees at the 25th, 50th or
75th percentile respectively. Also accepts a raw percentile number (0-100).
(default `:standard`)
- `:block_count`: Number of recent blocks to sample. (default `10`)
- `:rpc_client` / `:rpc_opts`: RPC related options.

## Returns
- `{:ok, %{max_fee_per_gas: integer, max_priority_fee_per_gas: integer}}` on success.
- `{:error, :invalid_speed}` if `:speed` is not recognized.
- `{:error, :no_fee_history_data}` if the node returned no usable fee history.
- `{:error, reason}` on RPC failures.
"""
@spec estimate_fees(Keyword.t()) ::
{:ok,
%{max_fee_per_gas: non_neg_integer(), max_priority_fee_per_gas: non_neg_integer()}}
| {:error, term()}
def estimate_fees(opts \\ []) do
block_count = Keyword.get(opts, :block_count, @fee_estimation_block_count)

with {:ok, percentile} <- fee_percentile(Keyword.get(opts, :speed, :standard)),
{:ok, fee_history} <- fee_history(block_count, "latest", [percentile], opts) do
calculate_fee_estimation(fee_history)
end
end

@doc """
Same as `Ethers.estimate_fees/1` but raises on error.
"""
@spec estimate_fees!(Keyword.t()) ::
%{max_fee_per_gas: non_neg_integer(), max_priority_fee_per_gas: non_neg_integer()}
| no_return()
def estimate_fees!(opts \\ []) do
case estimate_fees(opts) do
{:ok, fees} -> fees
{:error, reason} -> raise ExecutionError, reason
end
end

@doc """
Fetches the event logs with the given filter.

Expand Down Expand Up @@ -1067,6 +1190,25 @@ defmodule Ethers do
{:ok, log_params}
end

defp pre_process([block_count, newest_block, reward_percentiles], [], :fee_history, _opts)
when is_integer(block_count) and block_count > 0 and is_list(reward_percentiles) do
# Only one representation per input: quantities are integers (hex encoded here for
# the RPC), the newest block is an integer or one of the named block tags
case newest_block do
number when is_integer(number) and number >= 0 ->
{:ok,
[Utils.integer_to_hex(block_count), Utils.integer_to_hex(number), reward_percentiles]}

tag when tag in @block_tags ->
{:ok, [Utils.integer_to_hex(block_count), tag, reward_percentiles]}

_other ->
{:error, :invalid_fee_history_params}
end
end

defp pre_process(_data, [], :fee_history, _opts), do: {:error, :invalid_fee_history_params}

defp pre_process([], [], _action, _opts), do: :ok

defp pre_process(data, [], _action, _opts), do: {:ok, data}
Expand Down Expand Up @@ -1142,6 +1284,20 @@ defmodule Ethers do
defp post_process({:ok, nil}, _tx_hash, :get_transaction_receipt),
do: {:error, :transaction_receipt_not_found}

defp post_process({:ok, resp}, _data, :fee_history) when is_map(resp) do
{:ok,
%{
oldest_block: resp |> Map.get("oldestBlock") |> maybe_hex_to_integer(),
base_fee_per_gas:
resp |> Map.get("baseFeePerGas", []) |> Enum.map(&Utils.hex_to_integer!/1),
gas_used_ratio: Map.get(resp, "gasUsedRatio", []),
reward: resp |> Map.get("reward") |> decode_fee_history_rewards(),
base_fee_per_blob_gas:
resp |> Map.get("baseFeePerBlobGas") |> maybe_map(&Utils.hex_to_integer!/1),
blob_gas_used_ratio: Map.get(resp, "blobGasUsedRatio")
}}
end

defp post_process({:ok, result}, _tx_data, _action),
do: {:ok, result}

Expand All @@ -1168,6 +1324,66 @@ defmodule Ethers do
defp post_process({:error, cause}, _tx_data, _action),
do: {:error, cause}

defp fee_percentile(speed) when is_map_key(@fee_estimation_percentiles, speed),
do: {:ok, Map.fetch!(@fee_estimation_percentiles, speed)}

defp fee_percentile(percentile)
when is_number(percentile) and percentile >= 0 and percentile <= 100,
do: {:ok, percentile}

defp fee_percentile(_speed), do: {:error, :invalid_speed}

defp calculate_fee_estimation(%{base_fee_per_gas: base_fees, reward: rewards})
when base_fees != [] and is_list(rewards) do
# base_fee_per_gas has one extra entry: the expected base fee of the next block
next_base_fee = List.last(base_fees)

case rewards |> Enum.flat_map(&List.wrap/1) |> median() do
nil ->
{:error, :no_fee_history_data}

max_priority_fee_per_gas ->
{:ok,
%{
# Adds 100% headroom for base fee growth (one doubling) plus the tip
max_fee_per_gas: 2 * next_base_fee + max_priority_fee_per_gas,
Comment thread
alisinabh marked this conversation as resolved.
max_priority_fee_per_gas: max_priority_fee_per_gas
}}
end
end

defp calculate_fee_estimation(_fee_history), do: {:error, :no_fee_history_data}

defp median([]), do: nil

defp median(values) do
sorted = Enum.sort(values)
count = length(sorted)
middle = div(count, 2)

if rem(count, 2) == 1 do
Enum.at(sorted, middle)
else
div(Enum.at(sorted, middle - 1) + Enum.at(sorted, middle), 2)
end
end

defp decode_fee_history_rewards(nil), do: nil

defp decode_fee_history_rewards(rewards) when is_list(rewards),
do:
Enum.map(rewards, fn block_rewards -> maybe_map(block_rewards, &Utils.hex_to_integer!/1) end)

defp maybe_map(nil, _fun), do: nil
defp maybe_map(list, fun) when is_list(list), do: Enum.map(list, fun)

defp maybe_hex_to_integer(nil), do: nil
defp maybe_hex_to_integer(hex), do: Utils.hex_to_integer!(hex)

defp rpc_callback_supported?(rpc_client, callback, arity) do
Code.ensure_loaded?(rpc_client) and function_exported?(rpc_client, callback, arity)
end

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)}
Expand Down
12 changes: 12 additions & 0 deletions lib/ethers/rpc_client/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ defmodule Ethers.RpcClient.Adapter do

@callback eth_estimate_gas(map(), keyword()) :: {:ok, binary()} | error()

@callback eth_fee_history(
block_count :: binary(),
newest_block :: binary(),
reward_percentiles :: [number()],
keyword()
) :: {:ok, map()} | error()

@callback eth_gas_price(keyword()) :: {:ok, binary()} | error()

@callback eth_get_balance(binary(), binary(), keyword()) :: {:ok, binary()} | error()
Expand All @@ -36,4 +43,9 @@ defmodule Ethers.RpcClient.Adapter do
@callback eth_send_transaction(map(), keyword()) :: {:ok, binary()} | error()

@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
end
35 changes: 33 additions & 2 deletions lib/ethers/transaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ defmodule Ethers.Transaction do
# Margin precision is 0.01% (12345 = 123.45%)
@margin_precision 10_000

# Fields auto-filled through Ethers.estimate_fees/1 when the RPC client supports it
@fee_estimation_fields [:max_fee_per_gas, :max_priority_fee_per_gas]

@typedoc """
EVM Transaction type
"""
Expand Down Expand Up @@ -116,6 +119,11 @@ defmodule Ethers.Transaction do
@doc """
Fills missing transaction fields with default values from the network based on transaction type.

Missing `max_fee_per_gas` and `max_priority_fee_per_gas` values are estimated from recent
blocks with `Ethers.estimate_fees/1` (`eth_feeHistory`). When the RPC client does not
support `eth_feeHistory` (or the node returns no usable data), they fall back to the
legacy estimation based on `eth_gasPrice` and `eth_maxPriorityFeePerGas`.

## Parameters
- `params` - Updated Transaction params
- `opts` - Options to pass to the RPC client
Expand All @@ -128,9 +136,12 @@ defmodule Ethers.Transaction do
def add_auto_fetchable_fields(params, opts) do
params = Map.put_new(params, :type, @default_transaction_type)

missing_keys = Enum.reject(params.type.auto_fetchable_fields(), &Map.get(params, &1))

{params, missing_keys} = maybe_estimate_fees(params, missing_keys, opts)

{keys, actions} =
params.type.auto_fetchable_fields()
|> Enum.reject(&Map.get(params, &1))
missing_keys
|> Enum.map(&{&1, fill_action(&1, params)})
|> Enum.unzip()

Expand All @@ -146,6 +157,26 @@ defmodule Ethers.Transaction do
end
end

defp maybe_estimate_fees(params, missing_keys, opts) do
missing_fee_keys = Enum.filter(missing_keys, &(&1 in @fee_estimation_fields))

with [_ | _] <- missing_fee_keys,
true <- fee_history_supported?(opts),
{:ok, fees} <- Ethers.estimate_fees(opts) do
{Map.merge(params, Map.take(fees, missing_fee_keys)), missing_keys -- missing_fee_keys}
else
# Nothing to estimate or no eth_feeHistory support - fall back to the legacy
# gas price based fill actions for any missing fee field
_no_estimation -> {params, missing_keys}
end
end

defp fee_history_supported?(opts) do
{rpc_client, _rpc_opts} = Ethers.get_rpc_client(opts)

Code.ensure_loaded?(rpc_client) and function_exported?(rpc_client, :eth_fee_history, 4)
end

@doc """
Encodes a transaction for network transmission following EIP-155/EIP-1559.

Expand Down
Loading
Loading