From 5cbd5d0414e14097fe01c3c81830ec0677ebd383 Mon Sep 17 00:00:00 2001 From: David Dallaire Date: Sun, 24 Aug 2025 13:16:47 -0400 Subject: [PATCH 1/6] Add a way to fetch all contract events --- README.md | 3 + lib/ethers.ex | 90 +++++++++++++++++++++ test/ethers/counter_contract_test.exs | 111 ++++++++++++++++++++++++++ test/ethers_test.exs | 17 ++++ test/support/contracts/counter.sol | 6 ++ 5 files changed, 227 insertions(+) diff --git a/README.md b/README.md index 68177053..73d5cf75 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ filter = MyToken.EventFilters.transfer(from_address, nil) # Get matching events {:ok, events} = Ethers.get_logs(filter) + +# Get all events from a contract +{:ok, events} = Ethers.get_logs_for_contract(MyToken.EventFilters, "0x123...") ``` ## Documentation diff --git a/lib/ethers.ex b/lib/ethers.ex index d063a618..a28cb93b 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -584,6 +584,64 @@ defmodule Ethers do end end + @doc """ + Fetches event logs for all events in a contract's EventFilters module. + + This function is useful when you want to get all events from a contract without + specifying a single event filter. It will automatically decode each log using + the appropriate event selector from the EventFilters module. + + ## Parameters + - event_filters_module: The EventFilters module (e.g. `MyContract.EventFilters`) + - address: The contract address to filter events from (nil means all contracts) + + ## Overrides and Options + + - `: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.) + - `:fromBlock` | `:from_block`: Minimum block number of logs to filter. + - `:toBlock` | `:to_block`: Maximum block number of logs to filter. + + ## Examples + + ```elixir + # Get all events from a contract + {:ok, events} = Ethers.get_logs_for_contract(MyContract.EventFilters, "0x1234...") + + # Get all events with block range + {:ok, events} = Ethers.get_logs_for_contract(MyContract.EventFilters, "0x1234...", + fromBlock: 1000, + toBlock: 2000 + ) + ``` + """ + @spec get_logs_for_contract(module(), Types.t_address() | nil, Keyword.t()) :: + {:ok, [Event.t()]} | {:error, atom()} + def get_logs_for_contract(event_filters_module, address, overrides \\ []) do + overrides = Keyword.put(overrides, :address, address) + {opts, overrides} = Keyword.split(overrides, @option_keys) + + {rpc_client, rpc_opts} = get_rpc_client(opts) + + with {:ok, log_params} <- + pre_process(event_filters_module, overrides, :get_logs_for_contract, opts) do + rpc_client.eth_get_logs(log_params, rpc_opts) + |> post_process(event_filters_module, :get_logs_for_contract) + end + end + + @doc """ + Same as `Ethers.get_logs_for_contract/3` but raises on error. + """ + @spec get_logs_for_contract!(module(), Types.t_address() | nil, Keyword.t()) :: + [Event.t()] | no_return + def get_logs_for_contract!(event_filters_module, address, overrides \\ []) do + case get_logs_for_contract(event_filters_module, address, overrides) do + {:ok, events} -> events + {:error, reason} -> raise ExecutionError, reason + end + end + @doc """ Combines multiple requests and make a batch json RPC request. @@ -744,6 +802,21 @@ defmodule Ethers do end end + defp pre_process(_event_filters_module, overrides, :get_logs_for_contract, opts) do + {address, _opts} = Keyword.pop(opts, :address) + + log_params = + overrides + |> Enum.into(%{}) + |> ensure_hex_value(:fromBlock) + |> ensure_hex_value(:from_block) + |> ensure_hex_value(:toBlock) + |> ensure_hex_value(:to_block) + |> Map.put(:address, address) + + {:ok, log_params} + end + defp pre_process(event_filter, overrides, :get_logs, _opts) do log_params = event_filter @@ -806,6 +879,23 @@ defmodule Ethers do {:ok, resp} end + defp post_process({:ok, resp}, event_filters_module, :get_logs_for_contract) + when is_list(resp) do + logs = + Enum.flat_map(resp, fn log -> + case Event.find_and_decode(log, event_filters_module) do + {:ok, decoded_log} -> [decoded_log] + {:error, :not_found} -> [] + end + end) + + {:ok, logs} + end + + defp post_process({:ok, resp}, _event_filters_module, :get_logs_for_contract) do + {:ok, resp} + end + defp post_process({:ok, %{"contractAddress" => contract_address}}, _tx_hash, :deployed_address) when not is_nil(contract_address), do: {:ok, contract_address} diff --git a/test/ethers/counter_contract_test.exs b/test/ethers/counter_contract_test.exs index c44eab7b..a2781625 100644 --- a/test/ethers/counter_contract_test.exs +++ b/test/ethers/counter_contract_test.exs @@ -323,6 +323,117 @@ defmodule Ethers.CounterContractTest do end end + describe "get_logs_for_contract works with all events" do + setup :deploy_counter_contract + + test "can get all events from the contract", %{address: address} do + {:ok, tx_hash_1} = + CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_1) + + {:ok, tx_hash_2} = + CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_2) + + {:ok, current_block_number} = Ethers.current_block_number() + + assert {:ok, events} = + Ethers.get_logs_for_contract(CounterContract.EventFilters, address, + from_block: current_block_number - 2, + to_block: current_block_number + ) + + assert length(events) == 2 + + [set_called_event, reset_called_event] = events + + assert %Event{ + address: ^address, + topics: ["SetCalled(uint256,uint256)", 100], + data: [101] + } = set_called_event + + assert %Event{ + address: ^address, + topics: ["ResetCalled()"], + data: [] + } = reset_called_event + end + + test "can get all events with get_logs_for_contract! function", %{address: address} do + {:ok, tx_hash_1} = + CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_1) + + {:ok, tx_hash_2} = + CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_2) + + {:ok, current_block_number} = Ethers.current_block_number() + + events = + Ethers.get_logs_for_contract!(CounterContract.EventFilters, address, + from_block: current_block_number - 2, + to_block: current_block_number + ) + + assert [ + %Ethers.Event{ + address: ^address, + topics: ["SetCalled(uint256,uint256)", 100], + data: [101] + }, + %Ethers.Event{ + address: ^address, + topics: ["ResetCalled()"], + data: [] + } + ] = events + end + + test "can filter logs with from_block and to_block options", %{address: address} do + {:ok, tx_hash_1} = + CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_1) + + {:ok, tx_hash_2} = + CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_2) + + {:ok, current_block_number} = Ethers.current_block_number() + + assert [ + %Ethers.Event{ + address: ^address, + topics: ["SetCalled(uint256,uint256)", 100], + data: [101], + data_raw: "0x0000000000000000000000000000000000000000000000000000000000000065", + log_index: 0, + removed: false, + transaction_hash: ^tx_hash_1, + transaction_index: 0 + } + ] = + Ethers.get_logs_for_contract!(CounterContract.EventFilters, address, + from_block: current_block_number - 1, + to_block: current_block_number - 1 + ) + end + + test "returns empty list for non-existent contract address" do + fake_address = "0x1234567890123456789012345678901234567890" + + assert {:ok, []} = + Ethers.get_logs_for_contract(CounterContract.EventFilters, fake_address) + end + end + describe "override block number" do setup :deploy_counter_contract diff --git a/test/ethers_test.exs b/test/ethers_test.exs index 861d49ed..28d68943 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -352,6 +352,23 @@ defmodule EthersTest do end end + describe "get_logs_for_contract/3" do + test "returns error when request fails" do + assert {:error, %{reason: :nxdomain}} = + Ethers.get_logs_for_contract(HelloWorldContract.EventFilters, nil, + rpc_opts: [url: "http://non.exists"] + ) + end + + test "with bang function, raises error when request fails" do + assert_raise Mint.TransportError, "non-existing domain", fn -> + Ethers.get_logs_for_contract!(HelloWorldContract.EventFilters, nil, + rpc_opts: [url: "http://non.exists"] + ) + end + end + end + describe "default address" do test "is included in the function calls when has default address" do assert %Ethers.TxData{ diff --git a/test/support/contracts/counter.sol b/test/support/contracts/counter.sol index 9150e8b5..825f8bca 100644 --- a/test/support/contracts/counter.sol +++ b/test/support/contracts/counter.sol @@ -21,5 +21,11 @@ contract Counter { storeAmount = newAmount; } + function reset() public { + delete storeAmount; + emit ResetCalled(); + } + + event ResetCalled(); event SetCalled(uint256 indexed oldAmount, uint256 newAmount); } From efecb0648cbfe6708c8f60522f39912a39f54bae Mon Sep 17 00:00:00 2001 From: David Dallaire Date: Sun, 24 Aug 2025 13:16:47 -0400 Subject: [PATCH 2/6] Add a way to fetch all contract events --- lib/ethers.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/ethers.ex b/lib/ethers.ex index a28cb93b..50a3c1ea 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -802,9 +802,7 @@ defmodule Ethers do end end - defp pre_process(_event_filters_module, overrides, :get_logs_for_contract, opts) do - {address, _opts} = Keyword.pop(opts, :address) - + defp pre_process(_event_filters_module, overrides, :get_logs_for_contract, _opts) do log_params = overrides |> Enum.into(%{}) @@ -812,7 +810,6 @@ defmodule Ethers do |> ensure_hex_value(:from_block) |> ensure_hex_value(:toBlock) |> ensure_hex_value(:to_block) - |> Map.put(:address, address) {:ok, log_params} end From 11012d00650d508bbd198d8bea2c6880106643e0 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sun, 19 Jul 2026 02:12:14 -0400 Subject: [PATCH 3/6] Revert get_logs_for_contract in favor of combined event filters Keeps the Counter contract reset() event which the new tests reuse. Co-Authored-By: Claude Fable 5 --- README.md | 3 - lib/ethers.ex | 87 -------------------- test/ethers/counter_contract_test.exs | 111 -------------------------- test/ethers_test.exs | 17 ---- 4 files changed, 218 deletions(-) diff --git a/README.md b/README.md index 3ace7c20..0853f1a1 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,6 @@ filter = MyToken.EventFilters.transfer(from_address, nil) # Get matching events {:ok, events} = Ethers.get_logs(filter) - -# Get all events from a contract -{:ok, events} = Ethers.get_logs_for_contract(MyToken.EventFilters, "0x123...") ``` ## Documentation diff --git a/lib/ethers.ex b/lib/ethers.ex index aee5bf5c..ad3071a0 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -718,64 +718,6 @@ defmodule Ethers do end end - @doc """ - Fetches event logs for all events in a contract's EventFilters module. - - This function is useful when you want to get all events from a contract without - specifying a single event filter. It will automatically decode each log using - the appropriate event selector from the EventFilters module. - - ## Parameters - - event_filters_module: The EventFilters module (e.g. `MyContract.EventFilters`) - - address: The contract address to filter events from (nil means all contracts) - - ## Overrides and Options - - - `: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.) - - `:fromBlock` | `:from_block`: Minimum block number of logs to filter. - - `:toBlock` | `:to_block`: Maximum block number of logs to filter. - - ## Examples - - ```elixir - # Get all events from a contract - {:ok, events} = Ethers.get_logs_for_contract(MyContract.EventFilters, "0x1234...") - - # Get all events with block range - {:ok, events} = Ethers.get_logs_for_contract(MyContract.EventFilters, "0x1234...", - fromBlock: 1000, - toBlock: 2000 - ) - ``` - """ - @spec get_logs_for_contract(module(), Types.t_address() | nil, Keyword.t()) :: - {:ok, [Event.t()]} | {:error, atom()} - def get_logs_for_contract(event_filters_module, address, overrides \\ []) do - overrides = Keyword.put(overrides, :address, address) - {opts, overrides} = Keyword.split(overrides, @option_keys) - - {rpc_client, rpc_opts} = get_rpc_client(opts) - - with {:ok, log_params} <- - pre_process(event_filters_module, overrides, :get_logs_for_contract, opts) do - rpc_client.eth_get_logs(log_params, rpc_opts) - |> post_process(event_filters_module, :get_logs_for_contract) - end - end - - @doc """ - Same as `Ethers.get_logs_for_contract/3` but raises on error. - """ - @spec get_logs_for_contract!(module(), Types.t_address() | nil, Keyword.t()) :: - [Event.t()] | no_return - def get_logs_for_contract!(event_filters_module, address, overrides \\ []) do - case get_logs_for_contract(event_filters_module, address, overrides) do - {:ok, events} -> events - {:error, reason} -> raise ExecutionError, reason - end - end - @doc """ Combines multiple requests and make a batch json RPC request. @@ -936,18 +878,6 @@ defmodule Ethers do end end - defp pre_process(_event_filters_module, overrides, :get_logs_for_contract, _opts) do - log_params = - overrides - |> Enum.into(%{}) - |> ensure_hex_value(:fromBlock) - |> ensure_hex_value(:from_block) - |> ensure_hex_value(:toBlock) - |> ensure_hex_value(:to_block) - - {:ok, log_params} - end - defp pre_process(event_filter, overrides, :get_logs, _opts) do log_params = event_filter @@ -1010,23 +940,6 @@ defmodule Ethers do {:ok, resp} end - defp post_process({:ok, resp}, event_filters_module, :get_logs_for_contract) - when is_list(resp) do - logs = - Enum.flat_map(resp, fn log -> - case Event.find_and_decode(log, event_filters_module) do - {:ok, decoded_log} -> [decoded_log] - {:error, :not_found} -> [] - end - end) - - {:ok, logs} - end - - defp post_process({:ok, resp}, _event_filters_module, :get_logs_for_contract) do - {:ok, resp} - end - defp post_process({:ok, %{"contractAddress" => contract_address}}, _tx_hash, :deployed_address) when not is_nil(contract_address), do: {:ok, contract_address} diff --git a/test/ethers/counter_contract_test.exs b/test/ethers/counter_contract_test.exs index a2781625..c44eab7b 100644 --- a/test/ethers/counter_contract_test.exs +++ b/test/ethers/counter_contract_test.exs @@ -323,117 +323,6 @@ defmodule Ethers.CounterContractTest do end end - describe "get_logs_for_contract works with all events" do - setup :deploy_counter_contract - - test "can get all events from the contract", %{address: address} do - {:ok, tx_hash_1} = - CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) - - wait_for_transaction!(tx_hash_1) - - {:ok, tx_hash_2} = - CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) - - wait_for_transaction!(tx_hash_2) - - {:ok, current_block_number} = Ethers.current_block_number() - - assert {:ok, events} = - Ethers.get_logs_for_contract(CounterContract.EventFilters, address, - from_block: current_block_number - 2, - to_block: current_block_number - ) - - assert length(events) == 2 - - [set_called_event, reset_called_event] = events - - assert %Event{ - address: ^address, - topics: ["SetCalled(uint256,uint256)", 100], - data: [101] - } = set_called_event - - assert %Event{ - address: ^address, - topics: ["ResetCalled()"], - data: [] - } = reset_called_event - end - - test "can get all events with get_logs_for_contract! function", %{address: address} do - {:ok, tx_hash_1} = - CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) - - wait_for_transaction!(tx_hash_1) - - {:ok, tx_hash_2} = - CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) - - wait_for_transaction!(tx_hash_2) - - {:ok, current_block_number} = Ethers.current_block_number() - - events = - Ethers.get_logs_for_contract!(CounterContract.EventFilters, address, - from_block: current_block_number - 2, - to_block: current_block_number - ) - - assert [ - %Ethers.Event{ - address: ^address, - topics: ["SetCalled(uint256,uint256)", 100], - data: [101] - }, - %Ethers.Event{ - address: ^address, - topics: ["ResetCalled()"], - data: [] - } - ] = events - end - - test "can filter logs with from_block and to_block options", %{address: address} do - {:ok, tx_hash_1} = - CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) - - wait_for_transaction!(tx_hash_1) - - {:ok, tx_hash_2} = - CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) - - wait_for_transaction!(tx_hash_2) - - {:ok, current_block_number} = Ethers.current_block_number() - - assert [ - %Ethers.Event{ - address: ^address, - topics: ["SetCalled(uint256,uint256)", 100], - data: [101], - data_raw: "0x0000000000000000000000000000000000000000000000000000000000000065", - log_index: 0, - removed: false, - transaction_hash: ^tx_hash_1, - transaction_index: 0 - } - ] = - Ethers.get_logs_for_contract!(CounterContract.EventFilters, address, - from_block: current_block_number - 1, - to_block: current_block_number - 1 - ) - end - - test "returns empty list for non-existent contract address" do - fake_address = "0x1234567890123456789012345678901234567890" - - assert {:ok, []} = - Ethers.get_logs_for_contract(CounterContract.EventFilters, fake_address) - end - end - describe "override block number" do setup :deploy_counter_contract diff --git a/test/ethers_test.exs b/test/ethers_test.exs index f96f2ad4..f87b3837 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -357,23 +357,6 @@ defmodule EthersTest do end end - describe "get_logs_for_contract/3" do - test "returns error when request fails" do - assert {:error, %{reason: :nxdomain}} = - Ethers.get_logs_for_contract(HelloWorldContract.EventFilters, nil, - rpc_opts: [url: "http://non.exists"] - ) - end - - test "with bang function, raises error when request fails" do - assert_raise Mint.TransportError, "non-existing domain", fn -> - Ethers.get_logs_for_contract!(HelloWorldContract.EventFilters, nil, - rpc_opts: [url: "http://non.exists"] - ) - end - end - end - describe "default address" do test "is included in the function calls when has default address" do assert %Ethers.TxData{ From 2c9e29a521386fbcf0d234f9763de759c050f890 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sun, 19 Jul 2026 02:19:40 -0400 Subject: [PATCH 4/6] Add combined event filters for fetching multiple events in one request Introduce Ethers.CombinedEventFilter, created via Ethers.EventFilter.combine/1 or the generated EventFilters.all/0 function of contract modules. The combined topic_0 values are sent as an OR-ed list so filtering happens server side, and fetched logs are decoded with their matching event selector. Works with both Ethers.get_logs/2 and Ethers.batch/2. Combining validates that all filters use wildcard indexed arguments and have no conflicting default addresses (eth_getLogs accepts a single address). Also normalize from_block/to_block keys to fromBlock/toBlock in get_logs pre-processing, fixing batched get_logs requests with block ranges which bypassed Ethereumex's key normalization. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + README.md | 10 ++ lib/ethers.ex | 20 +++ lib/ethers/combined_event_filter.ex | 200 ++++++++++++++++++++++++++ lib/ethers/contract.ex | 29 ++++ lib/ethers/event_filter.ex | 43 +++++- test/ethers/counter_contract_test.exs | 152 ++++++++++++++++++++ test/ethers_test.exs | 34 +++++ 8 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 lib/ethers/combined_event_filter.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index f22c3f2c..1be6ca68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Enhancements +- Add combined event filters: fetch multiple events (OR semantics) in a single `eth_getLogs` + request with `Ethers.EventFilter.combine/1` or all events of a contract with the generated + `EventFilters.all/0` function (e.g. `MyContract.EventFilters.all()`) — the resulting + `Ethers.CombinedEventFilter` works with `Ethers.get_logs/2` and `Ethers.batch/2` and decodes + each fetched log with its matching event selector - Add [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal message support: hash, recover and verify messages with `Ethers.PersonalMessage`, and sign them via `Ethers.personal_sign/2` with the `Ethers.Signer.Local` and `Ethers.Signer.JsonRPC` signers through the new optional diff --git a/README.md b/README.md index 0853f1a1..d7db0941 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,16 @@ filter = MyToken.EventFilters.transfer(from_address, nil) # Get matching events {:ok, events} = Ethers.get_logs(filter) + +# Combine multiple events in a single request (topics are OR-ed) +filter = + Ethers.EventFilter.combine([ + MyToken.EventFilters.transfer(nil, nil), + MyToken.EventFilters.approval(nil, nil) + ]) + +# Or fetch all events of a contract +{:ok, events} = Ethers.get_logs(MyToken.EventFilters.all(), address: "0x...") ``` ## Documentation diff --git a/lib/ethers.ex b/lib/ethers.ex index ad3071a0..38f90b07 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -60,6 +60,7 @@ defmodule Ethers do ``` """ + alias Ethers.CombinedEventFilter alias Ethers.Event alias Ethers.EventFilter alias Ethers.ExecutionError @@ -687,6 +688,11 @@ defmodule Ethers do @doc """ Fetches the event logs with the given filter. + The filter can either be a single event filter (e.g. `MyContract.EventFilters.transfer(nil, nil)`) + or a combined event filter matching multiple events in one request. Combined filters + are created with `Ethers.EventFilter.combine/1` or a contract's generated + `EventFilters.all/0` function. (e.g. `MyContract.EventFilters.all()`) + ## Overrides and Options - `:address`: Indicates event emitter contract address. (nil means all contracts) @@ -886,6 +892,8 @@ defmodule Ethers do |> ensure_hex_value(:from_block) |> ensure_hex_value(:toBlock) |> ensure_hex_value(:to_block) + |> rename_key(:from_block, :fromBlock) + |> rename_key(:to_block, :toBlock) {:ok, log_params} end @@ -930,6 +938,11 @@ defmodule Ethers do Utils.hex_to_integer(gas_hex) end + defp post_process({:ok, resp}, %CombinedEventFilter{} = combined_filter, :get_logs) + when is_list(resp) do + {:ok, CombinedEventFilter.decode_logs(resp, combined_filter)} + end + defp post_process({:ok, resp}, event_filter, :get_logs) when is_list(resp) do logs = Enum.map(resp, &Event.decode(&1, event_filter.selector)) @@ -993,6 +1006,13 @@ defmodule Ethers do end end + defp rename_key(params, key, new_key) do + case Map.pop(params, key) do + {nil, params} -> params + {value, params} -> Map.put(params, new_key, value) + end + end + defp prepare_requests(requests) do Enum.map(requests, fn {action, data} -> {action, data, []} diff --git a/lib/ethers/combined_event_filter.ex b/lib/ethers/combined_event_filter.ex new file mode 100644 index 00000000..c703f489 --- /dev/null +++ b/lib/ethers/combined_event_filter.ex @@ -0,0 +1,200 @@ +defmodule Ethers.CombinedEventFilter do + @moduledoc """ + A filter matching any of multiple events (OR semantics) in a single `eth_getLogs` request. + + Combined event filters are created with `Ethers.EventFilter.combine/1` or with the + generated `EventFilters.all/0` function of a contract module and can be used anywhere + a regular event filter is accepted (e.g. `Ethers.get_logs/2` and `Ethers.batch/2`). + + The topics of the combined filters are sent to the RPC endpoint as an OR-ed list of + `topic_0` values so filtering happens server side. Fetched logs are decoded using the + event selector matching their first topic. + + ## Limitations + + - Only filters with wildcard indexed arguments (all `nil`) can be combined. Combining + filters with indexed-argument values would create cross-product OR semantics on the + topics and match unintended logs. + - All combined filters must belong to the same address. Filters with conflicting + default addresses cannot be combined since `eth_getLogs` accepts a single address. + + ## Examples + + ```elixir + filter = + Ethers.EventFilter.combine([ + Ethers.Contracts.ERC20.EventFilters.transfer(nil, nil), + Ethers.Contracts.ERC20.EventFilters.approval(nil, nil) + ]) + + Ethers.get_logs(filter, address: "0x...") + {:ok, [%Ethers.Event{...}, ...]} + ``` + """ + + alias Ethers.ContractHelpers + alias Ethers.Event + alias Ethers.EventFilter + + @typedoc """ + Holds the combined topics (a single OR-ed list of `topic_0` values), the event + selectors indexed by their `topic_0` and the default address. + """ + @type t :: %__MODULE__{ + topics: [[binary()]], + selectors: %{binary() => ABI.FunctionSelector.t()}, + default_address: nil | Ethers.Types.t_address() + } + + @enforce_keys [:topics, :selectors] + defstruct [:topics, :selectors, :default_address] + + @doc false + @spec new([EventFilter.t()]) :: t() + def new([_ | _] = event_filters) do + Enum.each(event_filters, &validate_filter!/1) + + {topic_0s, selectors} = + Enum.reduce(event_filters, {[], %{}}, fn %EventFilter{topics: [topic_0 | _]} = filter, + {topic_0s, selectors} -> + topic_0 = String.downcase(topic_0) + + if Map.has_key?(selectors, topic_0) do + {topic_0s, selectors} + else + {[topic_0 | topic_0s], Map.put(selectors, topic_0, filter.selector)} + end + end) + + %__MODULE__{ + topics: [Enum.reverse(topic_0s)], + selectors: selectors, + default_address: combined_default_address!(event_filters) + } + end + + def new(event_filters) when is_list(event_filters) do + raise ArgumentError, "cannot combine an empty list of event filters" + end + + @doc false + @spec from_events_module(module()) :: t() + def from_events_module(events_module) do + events_module.__events__() + |> Enum.map(fn selector -> + wildcard_args = Enum.map(ContractHelpers.event_indexed_types(selector), fn _ -> nil end) + + selector + |> ContractHelpers.encode_event_topics(wildcard_args) + |> EventFilter.new(selector, events_module.__default_address__()) + end) + |> new() + end + + @doc false + @spec to_map(t(), Keyword.t()) :: map() + def to_map(%__MODULE__{} = combined_filter, overrides) do + %{topics: combined_filter.topics} + |> maybe_add_address(combined_filter.default_address) + |> then(&Enum.into(overrides, &1)) + end + + @doc """ + Decodes fetched logs using the matching event selectors of the combined filter. + + Logs not matching any of the combined events are discarded. (Cannot happen with logs + fetched using the combined filter itself, since the RPC endpoint only returns logs + matching the requested topics) + """ + @spec decode_logs([map()], t()) :: [Event.t()] + def decode_logs(logs, %__MODULE__{selectors: selectors}) when is_list(logs) do + Enum.flat_map(logs, fn log -> + [topic_0 | _] = Map.fetch!(log, "topics") + + case Map.fetch(selectors, String.downcase(topic_0)) do + {:ok, selector} -> [Event.decode(log, selector)] + :error -> [] + end + end) + end + + defp validate_filter!(%EventFilter{topics: [_topic_0 | sub_topics]} = filter) do + if Enum.all?(sub_topics, &is_nil/1) do + :ok + else + raise ArgumentError, + "cannot combine event filter with indexed-argument values: #{inspect(filter)}" <> + " (use nil as a wildcard for all indexed arguments)" + end + end + + defp validate_filter!(other) do + raise ArgumentError, "expected an Ethers.EventFilter struct, got: #{inspect(other)}" + end + + defp combined_default_address!(event_filters) do + event_filters + |> Enum.map(& &1.default_address) + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(&String.downcase/1) + |> case do + [] -> + nil + + [address] -> + address + + addresses -> + raise ArgumentError, + "cannot combine event filters with conflicting default addresses: " <> + "#{inspect(addresses)} (eth_getLogs accepts a single address," <> + " combine filters of the same contract)" + end + end + + defp maybe_add_address(map, nil), do: map + defp maybe_add_address(map, address), do: Map.put(map, :address, address) + + defimpl Inspect do + import Inspect.Algebra + + def inspect( + %{topics: [topic_0s], selectors: selectors, default_address: default_address}, + opts + ) do + default_address = + case default_address do + nil -> + [] + + _ -> + [ + line(), + color("default_address: ", :default, opts), + color(inspect(default_address), :string, opts) + ] + end + + events = + topic_0s + |> Enum.map(&Map.fetch!(selectors, &1)) + |> Enum.map(fn selector -> + concat([ + line(), + color("event", :atom, opts), + " ", + color(ABI.FunctionSelector.encode(selector), :call, opts) + ]) + end) + + inner = concat(events ++ default_address) + + concat([ + color("#Ethers.CombinedEventFilter<", :map, opts), + nest(inner, 2), + break(""), + color(">", :map, opts) + ]) + end + end +end diff --git a/lib/ethers/contract.ex b/lib/ethers/contract.ex index 800c5d5b..059b4c6f 100644 --- a/lib/ethers/contract.ex +++ b/lib/ethers/contract.ex @@ -120,6 +120,7 @@ defmodule Ethers.Contract do events_impl = Enum.map(events, &impl(&1, module, impl_opts)) event_selectors = Enum.flat_map(events, & &1.selectors) + events_all_ast = events_all_impl(events, skip_docs) external_resource_ast = if abi_file do @@ -137,6 +138,7 @@ defmodule Ethers.Contract do defdelegate __default_address__, to: unquote(module) unquote(events_impl) + unquote(events_all_ast) def __events__, do: unquote(Macro.escape(event_selectors)) end @@ -351,6 +353,33 @@ defmodule Ethers.Contract do end end + defp events_all_impl([], _skip_docs), do: nil + + defp events_all_impl(events, skip_docs) do + # Skip generation if the contract has an `All()` event with no indexed arguments + # since it would collide with this function + if Enum.any?(events, &(Macro.underscore(&1.function) == "all" and &1.arity == 0)) do + nil + else + quote location: :keep do + if unquote(generate_docs?(:all, skip_docs)) do + @doc """ + Create a combined event filter matching any event of this contract. + + The filter can be used with `Ethers.get_logs/2` to fetch and decode all events + of this contract in a single request. See `Ethers.EventFilter.combine/1` for + combining a custom set of event filters. + """ + @spec all() :: Ethers.CombinedEventFilter.t() + end + + def all do + Ethers.CombinedEventFilter.from_events_module(__MODULE__) + end + end + end + end + defp errors_impl(selectors, mod) do errors_module = Module.concat([mod, Errors]) diff --git a/lib/ethers/event_filter.ex b/lib/ethers/event_filter.ex index 6865bd2f..5cdfaa89 100644 --- a/lib/ethers/event_filter.ex +++ b/lib/ethers/event_filter.ex @@ -27,14 +27,55 @@ defmodule Ethers.EventFilter do } end + @doc """ + Combines multiple event filters into a single `Ethers.CombinedEventFilter` matching + any of the given events (OR semantics) in one `eth_getLogs` request. + + The combined filter can be used anywhere a regular event filter is accepted + (e.g. `Ethers.get_logs/2` and `Ethers.batch/2`). Filtering happens server side using + an OR-ed list of `topic_0` values and each fetched log is decoded using its matching + event selector. + + To combine all events of a contract, use the generated `EventFilters.all/0` function + of the contract module instead. (e.g. `Ethers.Contracts.ERC20.EventFilters.all()`) + + ## Rules + + Raises `ArgumentError` if: + + - The list of filters is empty. + - Any filter has indexed-argument values. Only wildcard filters (all indexed + arguments set to `nil`) can be combined, since `eth_getLogs` topics are positional + and OR-ing them across events would match unintended logs. + - Filters have conflicting default addresses. `eth_getLogs` accepts a single address, + so all combined filters must belong to the same contract. + + ## Examples + + filter = + Ethers.EventFilter.combine([ + Ethers.Contracts.ERC20.EventFilters.transfer(nil, nil), + Ethers.Contracts.ERC20.EventFilters.approval(nil, nil) + ]) + + Ethers.get_logs(filter, address: "0x...") + #=> {:ok, [%Ethers.Event{...}, ...]} + """ + @spec combine([t()]) :: Ethers.CombinedEventFilter.t() + defdelegate combine(event_filters), to: Ethers.CombinedEventFilter, as: :new + @doc false - @spec to_map(t() | map(), Keyword.t()) :: map() + @spec to_map(t() | Ethers.CombinedEventFilter.t() | map(), Keyword.t()) :: map() def to_map(%__MODULE__{} = tx_data, overrides) do tx_data |> event_filter_map() |> to_map(overrides) end + def to_map(%Ethers.CombinedEventFilter{} = combined_filter, overrides) do + Ethers.CombinedEventFilter.to_map(combined_filter, overrides) + end + def to_map(event_filter, overrides) do Enum.into(overrides, event_filter) end diff --git a/test/ethers/counter_contract_test.exs b/test/ethers/counter_contract_test.exs index c44eab7b..878a02eb 100644 --- a/test/ethers/counter_contract_test.exs +++ b/test/ethers/counter_contract_test.exs @@ -323,6 +323,142 @@ defmodule Ethers.CounterContractTest do end end + describe "combined event filters" do + setup :deploy_counter_contract + + test "can fetch multiple events in a single request", %{address: address} do + %{block_range: block_range} = emit_set_and_reset(address) + + filter = + Ethers.EventFilter.combine([ + CounterContract.EventFilters.set_called(nil), + CounterContract.EventFilters.reset_called() + ]) + + assert {:ok, [set_called_event, reset_called_event]} = + Ethers.get_logs(filter, [address: address] ++ block_range) + + assert %Event{ + address: ^address, + topics: ["SetCalled(uint256,uint256)", 100], + data: [101] + } = set_called_event + + assert %Event{ + address: ^address, + topics: ["ResetCalled()"], + data: [] + } = reset_called_event + end + + test "EventFilters.all/0 matches all events of the contract", %{address: address} do + %{block_range: block_range} = emit_set_and_reset(address) + + assert [ + %Event{topics: ["SetCalled(uint256,uint256)", 100], data: [101]}, + %Event{topics: ["ResetCalled()"], data: []} + ] = + Ethers.get_logs!( + CounterContract.EventFilters.all(), + [address: address] ++ block_range + ) + end + + test "only fetches logs of the combined events", %{address: address} do + %{block_range: block_range} = emit_set_and_reset(address) + + filter = Ethers.EventFilter.combine([CounterContract.EventFilters.set_called(nil)]) + + assert {:ok, [%Event{topics: ["SetCalled(uint256,uint256)", 100]}]} = + Ethers.get_logs(filter, [address: address] ++ block_range) + end + + test "works with batch requests", %{address: address} do + %{block_range: block_range} = emit_set_and_reset(address) + + filter = + Ethers.EventFilter.combine([ + CounterContract.EventFilters.set_called(nil), + CounterContract.EventFilters.reset_called() + ]) + + assert {:ok, [ok: [set_called_event, reset_called_event]]} = + Ethers.batch([{:get_logs, filter, [address: address] ++ block_range}]) + + assert %Event{topics: ["SetCalled(uint256,uint256)", 100]} = set_called_event + assert %Event{topics: ["ResetCalled()"]} = reset_called_event + end + + test "uses the default address of the combined filters", %{address: address} do + %{block_range: block_range} = emit_set_and_reset(address) + + filter = + Ethers.EventFilter.combine([ + %{CounterContract.EventFilters.set_called(nil) | default_address: address}, + %{CounterContract.EventFilters.reset_called() | default_address: address} + ]) + + assert filter.default_address == address + + assert {:ok, [%Event{address: ^address}, %Event{address: ^address}]} = + Ethers.get_logs(filter, block_range) + end + + test "deduplicates repeated event filters" do + filter = + Ethers.EventFilter.combine([ + CounterContract.EventFilters.set_called(nil), + CounterContract.EventFilters.set_called(nil) + ]) + + assert [[_topic_0]] = filter.topics + end + + test "combining filters with indexed-argument values raises" do + assert_raise ArgumentError, ~r/indexed-argument values/, fn -> + Ethers.EventFilter.combine([CounterContract.EventFilters.set_called(100)]) + end + end + + test "combining an empty list raises" do + assert_raise ArgumentError, "cannot combine an empty list of event filters", fn -> + Ethers.EventFilter.combine([]) + end + end + + test "combining filters with conflicting default addresses raises" do + assert_raise ArgumentError, ~r/conflicting default addresses/, fn -> + Ethers.EventFilter.combine([ + %{ + CounterContract.EventFilters.set_called(nil) + | default_address: "0x1000bf6a479f320ead074411a4b0e7944ea8c9c1" + }, + %{ + CounterContract.EventFilters.reset_called() + | default_address: "0x2000bf6a479f320ead074411a4b0e7944ea8c9c2" + } + ]) + end + end + + test "combining non event filters raises" do + assert_raise ArgumentError, ~r/expected an Ethers.EventFilter struct/, fn -> + Ethers.EventFilter.combine([:not_a_filter]) + end + end + + test "renders the correct values when inspected" do + filter = + Ethers.EventFilter.combine([ + CounterContract.EventFilters.set_called(nil), + CounterContract.EventFilters.reset_called() + ]) + + assert "#Ethers.CombinedEventFilter<\n event SetCalled(uint256,uint256)\n event ResetCalled()>" == + inspect(filter) + end + end + describe "override block number" do setup :deploy_counter_contract @@ -351,6 +487,22 @@ defmodule Ethers.CounterContractTest do end end + defp emit_set_and_reset(address) do + {:ok, tx_hash_1} = + CounterContract.set(101) |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_1) + + {:ok, tx_hash_2} = + CounterContract.reset() |> Ethers.send_transaction(from: @from, to: address) + + wait_for_transaction!(tx_hash_2) + + {:ok, current_block_number} = Ethers.current_block_number() + + %{block_range: [from_block: current_block_number - 2, to_block: current_block_number]} + end + defp deploy_counter_contract(_ctx) do encoded_constructor = CounterContract.constructor(100) diff --git a/test/ethers_test.exs b/test/ethers_test.exs index f87b3837..8c8d5171 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -355,6 +355,17 @@ defmodule EthersTest do ) end end + + test "with combined event filter, returns error when request fails" do + filter = Ethers.EventFilter.combine([HelloWorldContract.EventFilters.hello_set()]) + + assert {:error, %{reason: :nxdomain}} = + Ethers.get_logs(filter, rpc_opts: [url: "http://non.exists"]) + + assert_raise Finch.TransportError, "non-existing domain", fn -> + Ethers.get_logs!(filter, rpc_opts: [url: "http://non.exists"]) + end + end end describe "default address" do @@ -432,6 +443,29 @@ defmodule EthersTest do |> Ethers.EventFilter.to_map([]) end + test "is included in combined event filters when has default address" do + filter = HelloWorldWithDefaultAddressContract.EventFilters.all() + + assert %Ethers.CombinedEventFilter{ + default_address: "0x1000bf6a479f320ead074411a4b0e7944ea8c9c1" + } = filter + + assert %{ + topics: [["0xbe6cf5e99b344c66895d6304d442b2f51b6359ee51ac581db2255de9373e24b8"]], + address: "0x1000bf6a479f320ead074411a4b0e7944ea8c9c1" + } == Ethers.EventFilter.to_map(filter, []) + end + + test "is not included in combined event filters when does not have default address" do + filter = HelloWorldContract.EventFilters.all() + + assert %Ethers.CombinedEventFilter{default_address: nil} = filter + + assert %{ + topics: [["0xbe6cf5e99b344c66895d6304d442b2f51b6359ee51ac581db2255de9373e24b8"]] + } == Ethers.EventFilter.to_map(filter, []) + end + test "is not included in event filters when does not have default address" do assert %Ethers.EventFilter{ topics: [ From e8a0a19b8e9dae04a039d7323a6a40ae2f40a568 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sun, 19 Jul 2026 02:59:25 -0400 Subject: [PATCH 5/6] Accept EventFilters modules for fetching all contract events Replace the generated EventFilters.all/0 function with a hidden __all__/0 (following the __events__/__default_address__ convention) to avoid colliding with contracts that define an All event. Instead, Ethers.get_logs/2, Ethers.batch/2 and Ethers.EventFilter.combine/1 now accept an EventFilters module name directly and translate it to __all__/0 internally. Also add the Ethers.Types.is_module/1 guard for module name detection. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +- README.md | 4 +- lib/ethers.ex | 46 +++++++--- lib/ethers/combined_event_filter.ex | 120 +++++++++++++++++--------- lib/ethers/contract.ex | 35 ++------ lib/ethers/event_filter.ex | 9 +- lib/ethers/types.ex | 5 ++ test/ethers/counter_contract_test.exs | 35 ++++++-- test/ethers_test.exs | 4 +- 9 files changed, 170 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be6ca68..c8b4e61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ ### Enhancements - Add combined event filters: fetch multiple events (OR semantics) in a single `eth_getLogs` - request with `Ethers.EventFilter.combine/1` or all events of a contract with the generated - `EventFilters.all/0` function (e.g. `MyContract.EventFilters.all()`) — the resulting - `Ethers.CombinedEventFilter` works with `Ethers.get_logs/2` and `Ethers.batch/2` and decodes - each fetched log with its matching event selector + request with `Ethers.EventFilter.combine/1`, or all events of a contract by passing its + EventFilters module (e.g. `Ethers.get_logs(MyContract.EventFilters, address: "0x...")`) — + the resulting `Ethers.CombinedEventFilter` works with `Ethers.get_logs/2` and + `Ethers.batch/2` and decodes each fetched log with its matching event selector - Add [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal message support: hash, recover and verify messages with `Ethers.PersonalMessage`, and sign them via `Ethers.personal_sign/2` with the `Ethers.Signer.Local` and `Ethers.Signer.JsonRPC` signers through the new optional diff --git a/README.md b/README.md index d7db0941..e1194a0c 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ filter = MyToken.EventFilters.approval(nil, nil) ]) -# Or fetch all events of a contract -{:ok, events} = Ethers.get_logs(MyToken.EventFilters.all(), address: "0x...") +# Or fetch all events of a contract by passing its EventFilters module +{:ok, events} = Ethers.get_logs(MyToken.EventFilters, address: "0x...") ``` ## Documentation diff --git a/lib/ethers.ex b/lib/ethers.ex index 38f90b07..88668207 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -36,7 +36,9 @@ defmodule Ethers do 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. - `: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 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 + are accepted. - `:send_transaction`: data should be a Ethers.TxData struct and overrides are accepted. @@ -69,6 +71,8 @@ defmodule Ethers do alias Ethers.Types alias Ethers.Utils + import Ethers.Types, only: [is_module: 1] + @option_keys [:rpc_client, :rpc_opts, :signer, :signer_opts] @hex_decode_post_process [ :chain_id, @@ -688,10 +692,13 @@ defmodule Ethers do @doc """ Fetches the event logs with the given filter. - The filter can either be a single event filter (e.g. `MyContract.EventFilters.transfer(nil, nil)`) - or a combined event filter matching multiple events in one request. Combined filters - are created with `Ethers.EventFilter.combine/1` or a contract's generated - `EventFilters.all/0` function. (e.g. `MyContract.EventFilters.all()`) + The filter can be one of: + + - A single event filter. (e.g. `MyContract.EventFilters.transfer(nil, nil)`) + - A combined event filter matching multiple events in one request, created with + `Ethers.EventFilter.combine/1`. + - An EventFilters module of a contract (e.g. `MyContract.EventFilters`) to match all + events of that contract. ## Overrides and Options @@ -701,8 +708,14 @@ 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(), Keyword.t()) :: {:ok, [Event.t()]} | {:error, atom()} - def get_logs(event_filter, overrides \\ []) do + @spec get_logs(map() | module(), Keyword.t()) :: {:ok, [Event.t()]} | {:error, atom()} + def get_logs(event_filter, overrides \\ []) + + def get_logs(events_module, overrides) when is_module(events_module) do + get_logs(CombinedEventFilter.all!(events_module), overrides) + end + + def get_logs(event_filter, overrides) do {opts, overrides} = Keyword.split(overrides, @option_keys) {rpc_client, rpc_opts} = get_rpc_client(opts) @@ -716,7 +729,7 @@ defmodule Ethers do @doc """ Same as `Ethers.get_logs/2` but raises on error. """ - @spec get_logs!(map(), Keyword.t()) :: [Event.t()] | no_return + @spec get_logs!(map() | module(), Keyword.t()) :: [Event.t()] | no_return def get_logs!(params, overrides \\ []) do case get_logs(params, overrides) do {:ok, logs} -> logs @@ -1015,9 +1028,20 @@ defmodule Ethers do defp prepare_requests(requests) do Enum.map(requests, fn - {action, data} -> {action, data, []} - {action, data, overrides} -> {action, data, overrides} - action when is_atom(action) -> {action, [], []} + {:get_logs, events_module} when is_module(events_module) -> + {:get_logs, CombinedEventFilter.all!(events_module), []} + + {:get_logs, events_module, overrides} when is_module(events_module) -> + {:get_logs, CombinedEventFilter.all!(events_module), overrides} + + {action, data} -> + {action, data, []} + + {action, data, overrides} -> + {action, data, overrides} + + action when is_atom(action) -> + {action, [], []} end) end diff --git a/lib/ethers/combined_event_filter.ex b/lib/ethers/combined_event_filter.ex index c703f489..463a4bac 100644 --- a/lib/ethers/combined_event_filter.ex +++ b/lib/ethers/combined_event_filter.ex @@ -2,9 +2,11 @@ defmodule Ethers.CombinedEventFilter do @moduledoc """ A filter matching any of multiple events (OR semantics) in a single `eth_getLogs` request. - Combined event filters are created with `Ethers.EventFilter.combine/1` or with the - generated `EventFilters.all/0` function of a contract module and can be used anywhere - a regular event filter is accepted (e.g. `Ethers.get_logs/2` and `Ethers.batch/2`). + Combined event filters are created with `Ethers.EventFilter.combine/1` and can be used + anywhere a regular event filter is accepted (e.g. `Ethers.get_logs/2` and + `Ethers.batch/2`). To match all events of a contract, pass its EventFilters module + directly to `Ethers.get_logs/2` (or `Ethers.EventFilter.combine/1`) which internally + translates it to a combined filter of all the contract events. The topics of the combined filters are sent to the RPC endpoint as an OR-ed list of `topic_0` values so filtering happens server side. Fetched logs are decoded using the @@ -29,6 +31,10 @@ defmodule Ethers.CombinedEventFilter do Ethers.get_logs(filter, address: "0x...") {:ok, [%Ethers.Event{...}, ...]} + + # Or fetch all events of a contract by passing its EventFilters module + Ethers.get_logs(Ethers.Contracts.ERC20.EventFilters, address: "0x...") + {:ok, [%Ethers.Event{...}, ...]} ``` """ @@ -36,6 +42,8 @@ defmodule Ethers.CombinedEventFilter do alias Ethers.Event alias Ethers.EventFilter + import Ethers.Types, only: [is_module: 1] + @typedoc """ Holds the combined topics (a single OR-ed list of `topic_0` values), the event selectors indexed by their `topic_0` and the default address. @@ -50,45 +58,40 @@ defmodule Ethers.CombinedEventFilter do defstruct [:topics, :selectors, :default_address] @doc false - @spec new([EventFilter.t()]) :: t() - def new([_ | _] = event_filters) do - Enum.each(event_filters, &validate_filter!/1) - - {topic_0s, selectors} = - Enum.reduce(event_filters, {[], %{}}, fn %EventFilter{topics: [topic_0 | _]} = filter, - {topic_0s, selectors} -> - topic_0 = String.downcase(topic_0) - - if Map.has_key?(selectors, topic_0) do - {topic_0s, selectors} - else - {[topic_0 | topic_0s], Map.put(selectors, topic_0, filter.selector)} - end - end) + @spec new([EventFilter.t() | module()] | module()) :: t() + def new(events_module) when is_module(events_module), do: new([events_module]) - %__MODULE__{ - topics: [Enum.reverse(topic_0s)], - selectors: selectors, - default_address: combined_default_address!(event_filters) - } - end + def new(filters_or_modules) when is_list(filters_or_modules) do + case Enum.flat_map(filters_or_modules, &expand_filters/1) do + [] -> + raise ArgumentError, "cannot combine an empty list of event filters" + + event_filters -> + Enum.each(event_filters, &validate_filter!/1) + + {topic_0s, selectors} = Enum.reduce(event_filters, {[], %{}}, &collect_topic_0/2) - def new(event_filters) when is_list(event_filters) do - raise ArgumentError, "cannot combine an empty list of event filters" + %__MODULE__{ + topics: [Enum.reverse(topic_0s)], + selectors: selectors, + default_address: combined_default_address!(event_filters) + } + end end @doc false @spec from_events_module(module()) :: t() - def from_events_module(events_module) do - events_module.__events__() - |> Enum.map(fn selector -> - wildcard_args = Enum.map(ContractHelpers.event_indexed_types(selector), fn _ -> nil end) - - selector - |> ContractHelpers.encode_event_topics(wildcard_args) - |> EventFilter.new(selector, events_module.__default_address__()) - end) - |> new() + def from_events_module(events_module), do: new([events_module]) + + @doc false + @spec all!(module()) :: t() + def all!(events_module) when is_module(events_module) do + if Code.ensure_loaded?(events_module) and function_exported?(events_module, :__all__, 0) do + events_module.__all__() + else + raise ArgumentError, + "#{inspect(events_module)} is not an EventFilters module of an Ethers contract" + end end @doc false @@ -118,6 +121,47 @@ defmodule Ethers.CombinedEventFilter do end) end + defp expand_filters(%EventFilter{} = filter), do: [filter] + + defp expand_filters(events_module) when is_module(events_module) do + unless Code.ensure_loaded?(events_module) and + function_exported?(events_module, :__events__, 0) do + raise ArgumentError, + "#{inspect(events_module)} is not an EventFilters module of an Ethers contract" + end + + case events_module.__events__() do + [] -> + raise ArgumentError, "#{inspect(events_module)} does not have any events" + + selectors -> + Enum.map(selectors, &wildcard_filter(&1, events_module.__default_address__())) + end + end + + defp expand_filters(other) do + raise ArgumentError, + "expected an Ethers.EventFilter struct or an EventFilters module, got: #{inspect(other)}" + end + + defp wildcard_filter(selector, default_address) do + wildcard_args = Enum.map(ContractHelpers.event_indexed_types(selector), fn _ -> nil end) + + selector + |> ContractHelpers.encode_event_topics(wildcard_args) + |> EventFilter.new(selector, default_address) + end + + defp collect_topic_0(%EventFilter{topics: [topic_0 | _]} = filter, {topic_0s, selectors}) do + topic_0 = String.downcase(topic_0) + + if Map.has_key?(selectors, topic_0) do + {topic_0s, selectors} + else + {[topic_0 | topic_0s], Map.put(selectors, topic_0, filter.selector)} + end + end + defp validate_filter!(%EventFilter{topics: [_topic_0 | sub_topics]} = filter) do if Enum.all?(sub_topics, &is_nil/1) do :ok @@ -128,10 +172,6 @@ defmodule Ethers.CombinedEventFilter do end end - defp validate_filter!(other) do - raise ArgumentError, "expected an Ethers.EventFilter struct, got: #{inspect(other)}" - end - defp combined_default_address!(event_filters) do event_filters |> Enum.map(& &1.default_address) diff --git a/lib/ethers/contract.ex b/lib/ethers/contract.ex index 059b4c6f..385c883a 100644 --- a/lib/ethers/contract.ex +++ b/lib/ethers/contract.ex @@ -120,7 +120,6 @@ defmodule Ethers.Contract do events_impl = Enum.map(events, &impl(&1, module, impl_opts)) event_selectors = Enum.flat_map(events, & &1.selectors) - events_all_ast = events_all_impl(events, skip_docs) external_resource_ast = if abi_file do @@ -138,7 +137,12 @@ defmodule Ethers.Contract do defdelegate __default_address__, to: unquote(module) unquote(events_impl) - unquote(events_all_ast) + + @doc false + @spec __all__() :: Ethers.CombinedEventFilter.t() + def __all__ do + Ethers.CombinedEventFilter.from_events_module(__MODULE__) + end def __events__, do: unquote(Macro.escape(event_selectors)) end @@ -353,33 +357,6 @@ defmodule Ethers.Contract do end end - defp events_all_impl([], _skip_docs), do: nil - - defp events_all_impl(events, skip_docs) do - # Skip generation if the contract has an `All()` event with no indexed arguments - # since it would collide with this function - if Enum.any?(events, &(Macro.underscore(&1.function) == "all" and &1.arity == 0)) do - nil - else - quote location: :keep do - if unquote(generate_docs?(:all, skip_docs)) do - @doc """ - Create a combined event filter matching any event of this contract. - - The filter can be used with `Ethers.get_logs/2` to fetch and decode all events - of this contract in a single request. See `Ethers.EventFilter.combine/1` for - combining a custom set of event filters. - """ - @spec all() :: Ethers.CombinedEventFilter.t() - end - - def all do - Ethers.CombinedEventFilter.from_events_module(__MODULE__) - end - end - end - end - defp errors_impl(selectors, mod) do errors_module = Module.concat([mod, Errors]) diff --git a/lib/ethers/event_filter.ex b/lib/ethers/event_filter.ex index 5cdfaa89..fa1d85c7 100644 --- a/lib/ethers/event_filter.ex +++ b/lib/ethers/event_filter.ex @@ -36,8 +36,8 @@ defmodule Ethers.EventFilter do an OR-ed list of `topic_0` values and each fetched log is decoded using its matching event selector. - To combine all events of a contract, use the generated `EventFilters.all/0` function - of the contract module instead. (e.g. `Ethers.Contracts.ERC20.EventFilters.all()`) + Accepts event filters as well as EventFilters modules of contracts, which translate + to all events of that contract. (e.g. `Ethers.Contracts.ERC20.EventFilters`) ## Rules @@ -60,8 +60,11 @@ defmodule Ethers.EventFilter do Ethers.get_logs(filter, address: "0x...") #=> {:ok, [%Ethers.Event{...}, ...]} + + # All events of a contract + Ethers.EventFilter.combine([Ethers.Contracts.ERC20.EventFilters]) """ - @spec combine([t()]) :: Ethers.CombinedEventFilter.t() + @spec combine([t() | module()] | module()) :: Ethers.CombinedEventFilter.t() defdelegate combine(event_filters), to: Ethers.CombinedEventFilter, as: :new @doc false diff --git a/lib/ethers/types.ex b/lib/ethers/types.ex index 47a634f1..c551101c 100644 --- a/lib/ethers/types.ex +++ b/lib/ethers/types.ex @@ -54,6 +54,11 @@ defmodule Ethers.Types do defguardp valid_bitsize(bitsize) when bitsize >= 8 and bitsize <= 256 and rem(bitsize, 8) == 0 defguardp valid_bytesize(bytesize) when bytesize >= 1 and bytesize <= 32 + @doc """ + Guard to check if the given term can be a module name. (an atom excluding `nil` and booleans) + """ + defguard is_module(term) when is_atom(term) and term not in [nil, true, false] + @doc """ Converts EVM data types to typespecs for documentation """ diff --git a/test/ethers/counter_contract_test.exs b/test/ethers/counter_contract_test.exs index 878a02eb..90023b0f 100644 --- a/test/ethers/counter_contract_test.exs +++ b/test/ethers/counter_contract_test.exs @@ -351,7 +351,7 @@ defmodule Ethers.CounterContractTest do } = reset_called_event end - test "EventFilters.all/0 matches all events of the contract", %{address: address} do + test "passing an EventFilters module matches all events of the contract", %{address: address} do %{block_range: block_range} = emit_set_and_reset(address) assert [ @@ -359,11 +359,23 @@ defmodule Ethers.CounterContractTest do %Event{topics: ["ResetCalled()"], data: []} ] = Ethers.get_logs!( - CounterContract.EventFilters.all(), + CounterContract.EventFilters, [address: address] ++ block_range ) end + test "combine accepts EventFilters modules", %{address: address} do + %{block_range: block_range} = emit_set_and_reset(address) + + filter = Ethers.EventFilter.combine([CounterContract.EventFilters]) + + assert filter == Ethers.EventFilter.combine(CounterContract.EventFilters) + assert filter == CounterContract.EventFilters.__all__() + + assert {:ok, [%Event{}, %Event{}]} = + Ethers.get_logs(filter, [address: address] ++ block_range) + end + test "only fetches logs of the combined events", %{address: address} do %{block_range: block_range} = emit_set_and_reset(address) @@ -382,8 +394,11 @@ defmodule Ethers.CounterContractTest do CounterContract.EventFilters.reset_called() ]) - assert {:ok, [ok: [set_called_event, reset_called_event]]} = - Ethers.batch([{:get_logs, filter, [address: address] ++ block_range}]) + assert {:ok, [ok: [set_called_event, reset_called_event], ok: [_, _]]} = + Ethers.batch([ + {:get_logs, filter, [address: address] ++ block_range}, + {:get_logs, CounterContract.EventFilters, [address: address] ++ block_range} + ]) assert %Event{topics: ["SetCalled(uint256,uint256)", 100]} = set_called_event assert %Event{topics: ["ResetCalled()"]} = reset_called_event @@ -442,8 +457,18 @@ defmodule Ethers.CounterContractTest do end test "combining non event filters raises" do + assert_raise ArgumentError, ~r/is not an EventFilters module/, fn -> + Ethers.EventFilter.combine([:not_a_module]) + end + assert_raise ArgumentError, ~r/expected an Ethers.EventFilter struct/, fn -> - Ethers.EventFilter.combine([:not_a_filter]) + Ethers.EventFilter.combine([%{some: :map}]) + end + end + + test "get_logs with a non EventFilters module raises" do + assert_raise ArgumentError, ~r/is not an EventFilters module/, fn -> + Ethers.get_logs(:not_a_module) end end diff --git a/test/ethers_test.exs b/test/ethers_test.exs index 8c8d5171..adc7c24f 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -444,7 +444,7 @@ defmodule EthersTest do end test "is included in combined event filters when has default address" do - filter = HelloWorldWithDefaultAddressContract.EventFilters.all() + filter = HelloWorldWithDefaultAddressContract.EventFilters.__all__() assert %Ethers.CombinedEventFilter{ default_address: "0x1000bf6a479f320ead074411a4b0e7944ea8c9c1" @@ -457,7 +457,7 @@ defmodule EthersTest do end test "is not included in combined event filters when does not have default address" do - filter = HelloWorldContract.EventFilters.all() + filter = Ethers.EventFilter.combine(HelloWorldContract.EventFilters) assert %Ethers.CombinedEventFilter{default_address: nil} = filter From 71e5cb72366de06f6afc503e94156bc48747fc4c Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Sun, 19 Jul 2026 03:05:34 -0400 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/ethers.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ethers.ex b/lib/ethers.ex index 88668207..2617e2fd 100644 --- a/lib/ethers.ex +++ b/lib/ethers.ex @@ -708,7 +708,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, atom()} +@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