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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 by passing its EventFilters module
{:ok, events} = Ethers.get_logs(MyToken.EventFilters, address: "0x...")
```

## Documentation
Expand Down
58 changes: 51 additions & 7 deletions lib/ethers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand All @@ -60,6 +62,7 @@ defmodule Ethers do
```
"""

alias Ethers.CombinedEventFilter
alias Ethers.Event
alias Ethers.EventFilter
alias Ethers.ExecutionError
Expand All @@ -68,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,
Expand Down Expand Up @@ -687,6 +692,14 @@ defmodule Ethers do
@doc """
Fetches the event logs with the given filter.

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

- `:address`: Indicates event emitter contract address. (nil means all contracts)
Expand All @@ -695,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, term()}
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
Comment thread
alisinabh marked this conversation as resolved.
{opts, overrides} = Keyword.split(overrides, @option_keys)

{rpc_client, rpc_opts} = get_rpc_client(opts)
Expand All @@ -710,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
Expand Down Expand Up @@ -886,6 +905,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
Expand Down Expand Up @@ -930,6 +951,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))

Expand Down Expand Up @@ -993,11 +1019,29 @@ 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, []}
{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

Expand Down
240 changes: 240 additions & 0 deletions lib/ethers/combined_event_filter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
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` 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
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{...}, ...]}

# Or fetch all events of a contract by passing its EventFilters module
Ethers.get_logs(Ethers.Contracts.ERC20.EventFilters, address: "0x...")
{:ok, [%Ethers.Event{...}, ...]}
```
"""

alias Ethers.ContractHelpers
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.
"""
@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() | module()] | module()) :: t()
def new(events_module) when is_module(events_module), do: new([events_module])

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)

%__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: 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
@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 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
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 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
Loading
Loading