diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex
new file mode 100644
index 0000000000..6ecc0d5f36
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex
@@ -0,0 +1,238 @@
+defmodule BlockScoutWeb.API.RPC.LogsController do
+ use BlockScoutWeb, :controller
+
+ alias Explorer.{Etherscan, Chain}
+
+ def getlogs(conn, params) do
+ with {:required_params, {:ok, fetched_params}} <- fetch_required_params(params),
+ {:format, {:ok, validated_params}} <- to_valid_format(fetched_params),
+ {:ok, logs} <- list_logs(validated_params) do
+ render(conn, :getlogs, %{logs: logs})
+ else
+ {:required_params, {:error, missing_params}} ->
+ error = "Required query parameters missing: #{Enum.join(missing_params, ", ")}"
+ render(conn, :error, error: error)
+
+ {:format, {:error, param}} ->
+ render(conn, :error, error: "Invalid #{param} format")
+
+ {:error, :not_found} ->
+ render(conn, :error, error: "No logs found", data: [])
+ end
+ end
+
+ # Interpretation of `@maybe_required_params`:
+ #
+ # If a pair of `topic{x}` params is provided, then the corresponding
+ # `topic{x}_{x}_opr` param is required.
+ #
+ # For example, if "topic0" and "topic1" are provided, then "topic0_1_opr" is
+ # required.
+ #
+ @maybe_required_params %{
+ ["topic0", "topic1"] => "topic0_1_opr",
+ ["topic0", "topic2"] => "topic0_2_opr",
+ ["topic0", "topic3"] => "topic0_3_opr",
+ ["topic1", "topic2"] => "topic1_2_opr",
+ ["topic1", "topic3"] => "topic1_3_opr",
+ ["topic2", "topic3"] => "topic2_3_opr"
+ }
+
+ @required_params %{
+ # all_of: all of these parameters are required
+ all_of: ["fromBlock", "toBlock"],
+ # one_of: at least one of these parameters is required
+ one_of: ["address", "topic0", "topic1", "topic2", "topic3"]
+ }
+
+ @doc """
+ Fetches required params. Returns error tuple if required params are missing.
+
+ """
+ @spec fetch_required_params(map()) :: {:required_params, {:ok, map()} | {:error, [String.t(), ...]}}
+ def fetch_required_params(params) do
+ all_of_params = fetch_required_params(params, :all_of)
+ one_of_params = fetch_required_params(params, :one_of)
+ maybe_params = fetch_required_params(params, :maybe)
+
+ result =
+ case {all_of_params, one_of_params, maybe_params} do
+ {{:error, missing_params}, {:error, _}, _} ->
+ {:error, Enum.concat(missing_params, ["address and/or topic{x}"])}
+
+ {{:error, missing_params}, {:ok, _}, _} ->
+ {:error, missing_params}
+
+ {{:ok, _}, {:error, _}, _} ->
+ {:error, ["address and/or topic{x}"]}
+
+ {{:ok, _}, {:ok, _}, {:error, missing_params}} ->
+ {:error, missing_params}
+
+ {{:ok, all_of_params}, {:ok, one_of_params}, {:ok, maybe_params}} ->
+ fetched_params =
+ all_of_params
+ |> Map.merge(one_of_params)
+ |> Map.merge(maybe_params)
+
+ {:ok, fetched_params}
+ end
+
+ {:required_params, result}
+ end
+
+ @doc """
+ Prepares params for processing. Returns error tuple if invalid format is
+ found.
+
+ """
+ @spec to_valid_format(map()) :: {:format, {:ok, map()} | {:error, String.t()}}
+ def to_valid_format(params) do
+ result =
+ with {:ok, from_block} <- to_block_number(params, "fromBlock"),
+ {:ok, to_block} <- to_block_number(params, "toBlock"),
+ {:ok, address_hash} <- to_address_hash(params["address"]),
+ :ok <- validate_topic_operators(params) do
+ validated_params = %{
+ from_block: from_block,
+ to_block: to_block,
+ address_hash: address_hash,
+ first_topic: params["topic0"],
+ second_topic: params["topic1"],
+ third_topic: params["topic2"],
+ fourth_topic: params["topic3"],
+ topic0_1_opr: params["topic0_1_opr"],
+ topic0_2_opr: params["topic0_2_opr"],
+ topic0_3_opr: params["topic0_3_opr"],
+ topic1_2_opr: params["topic1_2_opr"],
+ topic1_3_opr: params["topic1_3_opr"],
+ topic2_3_opr: params["topic2_3_opr"]
+ }
+
+ {:ok, validated_params}
+ else
+ {:error, param_key} ->
+ {:error, param_key}
+ end
+
+ {:format, result}
+ end
+
+ defp fetch_required_params(params, :all_of) do
+ fetched_params = Map.take(params, @required_params.all_of)
+
+ if all_of_required_keys_found?(fetched_params) do
+ {:ok, fetched_params}
+ else
+ missing_params = get_missing_required_params(fetched_params, :all_of)
+ {:error, missing_params}
+ end
+ end
+
+ defp fetch_required_params(params, :one_of) do
+ fetched_params = Map.take(params, @required_params.one_of)
+ found_keys = Map.keys(fetched_params)
+
+ if length(found_keys) > 0 do
+ {:ok, fetched_params}
+ else
+ {:error, @required_params.one_of}
+ end
+ end
+
+ defp fetch_required_params(params, :maybe) do
+ case get_missing_required_params(params, :maybe) do
+ [] ->
+ keys_to_fetch = Map.values(@maybe_required_params)
+ {:ok, Map.take(params, keys_to_fetch)}
+
+ missing_params ->
+ {:error, Enum.reverse(missing_params)}
+ end
+ end
+
+ defp all_of_required_keys_found?(fetched_params) do
+ Enum.all?(@required_params.all_of, &Map.has_key?(fetched_params, &1))
+ end
+
+ defp get_missing_required_params(fetched_params, :all_of) do
+ fetched_keys = fetched_params |> Map.keys() |> MapSet.new()
+
+ @required_params.all_of
+ |> MapSet.new()
+ |> MapSet.difference(fetched_keys)
+ |> MapSet.to_list()
+ end
+
+ defp get_missing_required_params(fetched_params, :maybe) do
+ Enum.reduce(@maybe_required_params, [], fn {[key1, key2], expectation}, missing_params ->
+ has_key1? = Map.has_key?(fetched_params, key1)
+ has_key2? = Map.has_key?(fetched_params, key2)
+ has_expectation? = Map.has_key?(fetched_params, expectation)
+
+ case {has_key1?, has_key2?, has_expectation?} do
+ {true, true, false} ->
+ [expectation | missing_params]
+
+ _ ->
+ missing_params
+ end
+ end)
+ end
+
+ defp to_block_number(params, param_key) do
+ case params[param_key] do
+ "latest" ->
+ Chain.max_block_number()
+
+ _ ->
+ to_integer(params, param_key)
+ end
+ end
+
+ defp to_integer(params, param_key) do
+ case Integer.parse(params[param_key]) do
+ {integer, ""} ->
+ {:ok, integer}
+
+ _ ->
+ {:error, param_key}
+ end
+ end
+
+ defp to_address_hash(nil), do: {:ok, nil}
+
+ defp to_address_hash(address_hash_string) do
+ case Chain.string_to_address_hash(address_hash_string) do
+ :error ->
+ {:error, "address"}
+
+ {:ok, address_hash} ->
+ {:ok, address_hash}
+ end
+ end
+
+ defp validate_topic_operators(params) do
+ topic_operator_keys = Map.values(@maybe_required_params)
+
+ first_invalid_topic_operator =
+ Enum.find(topic_operator_keys, fn topic_operator ->
+ params[topic_operator] not in ["and", "or", nil]
+ end)
+
+ case first_invalid_topic_operator do
+ nil ->
+ :ok
+
+ invalid_topic_operator ->
+ {:error, invalid_topic_operator}
+ end
+ end
+
+ defp list_logs(filter) do
+ case Etherscan.list_logs(filter) do
+ [] -> {:error, :not_found}
+ logs -> {:ok, logs}
+ end
+ end
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/etherscan.ex b/apps/block_scout_web/lib/block_scout_web/etherscan.ex
index 9fc47e7056..10ccec507f 100644
--- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex
+++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex
@@ -68,6 +68,35 @@ defmodule BlockScoutWeb.Etherscan do
"result" => []
}
+ @logs_getlogs_example_value %{
+ "status" => "1",
+ "message" => "OK",
+ result: [
+ %{
+ "address" => "0x33990122638b9132ca29c723bdf037f1a891a70c",
+ "topics" => [
+ "0xf63780e752c6a54a94fc52715dbc5518a3b4c3c2833d301a204226548a2a8545",
+ "0x72657075746174696f6e00000000000000000000000000000000000000000000",
+ "0x000000000000000000000000d9b2f59f3b5c7b3c67047d2f03c3e8052470be92"
+ ],
+ "data" => "0x",
+ "blockNumber" => "0x5c958",
+ "timeStamp" => "0x561d688c",
+ "gasPrice" => "0xba43b7400",
+ "gasUsed" => "0x10682",
+ "logIndex" => "0x",
+ "transactionHash" => "0x0b03498648ae2da924f961dda00dc6bb0a8df15519262b7e012b7d67f4bb7e83",
+ "transactionIndex" => "0x"
+ }
+ ]
+ }
+
+ @logs_getlogs_example_value_error %{
+ "status" => "0",
+ "message" => "Invalid address format",
+ "result" => nil
+ }
+
@status_type %{
type: "status",
enum: ~s(["0", "1"]),
@@ -170,6 +199,52 @@ defmodule BlockScoutWeb.Etherscan do
}
}
+ @log %{
+ name: "Log",
+ fields: %{
+ address: @address_hash_type,
+ topics: %{
+ type: "topics",
+ definition: "An array including the topics for the log.",
+ example: ~s(["0xf63780e752c6a54a94fc52715dbc5518a3b4c3c2833d301a204226548a2a8545"])
+ },
+ data: %{
+ type: "data",
+ definition: "Non-indexed log parameters.",
+ example: ~s("0x")
+ },
+ blockNumber: %{
+ type: "block number",
+ definition: "A nonnegative number used to identify blocks.",
+ example: ~s("0x5c958")
+ },
+ timeStamp: %{
+ type: "timestamp",
+ definition: "The transaction's block-timestamp.",
+ example: ~s("0x561d688c")
+ },
+ gasPrice: %{
+ type: "wei",
+ definition: &__MODULE__.wei_type_definition/1,
+ example: ~s("0xba43b7400")
+ },
+ gasUsed: %{
+ type: "gas",
+ definition: "A nonnegative number roughly equivalent to computational steps.",
+ example: ~s("0x10682")
+ },
+ logIndex: %{
+ type: "hexadecimal",
+ example: ~s("0x")
+ },
+ transactionHash: @transaction_hash_type,
+ transactionIndex: %{
+ type: "hexadecimal",
+ example: ~s("0x")
+ }
+ }
+ }
+
@account_balance_action %{
name: "balance",
description: "Get balance for address",
@@ -308,6 +383,121 @@ defmodule BlockScoutWeb.Etherscan do
]
}
+ @logs_getlogs_action %{
+ name: "getLogs",
+ description: "Get event logs for an address and/or topics. Up to a maximum of 1,000 event logs.",
+ required_params: [
+ %{
+ key: "fromBlock",
+ placeholder: "blockNumber",
+ type: "integer",
+ description:
+ "A nonnegative integer that represents the starting block number. The use of 'latest' is also supported."
+ },
+ %{
+ key: "toBlock",
+ placeholder: "blockNumber",
+ type: "integer",
+ description:
+ "A nonnegative integer that represents the ending block number. The use of 'latest' is also supported."
+ },
+ %{
+ key: "address",
+ placeholder: "addressHash",
+ type: "string",
+ description: "A 160-bit code used for identifying contracts. An address and/or topic{x} is required."
+ },
+ %{
+ key: "topic0",
+ placeholder: "firstTopic",
+ type: "string",
+ description: "A string equal to the first topic. A topic{x} and/or address is required."
+ }
+ ],
+ optional_params: [
+ %{
+ key: "topic1",
+ type: "string",
+ description: "A string equal to the second topic. A topic{x} and/or address is required."
+ },
+ %{
+ key: "topic2",
+ type: "string",
+ description: "A string equal to the third topic. A topic{x} and/or address is required."
+ },
+ %{
+ key: "topic3",
+ type: "string",
+ description: "A string equal to the fourth topic. A topic{x} and/or address is required."
+ },
+ %{
+ key: "topic0_1_opr",
+ type: "string",
+ description:
+ "A string representing the and|or operator for topic0 and topic1. " <>
+ "Required if topic0 and topic1 is used. Available values: and, or"
+ },
+ %{
+ key: "topic0_2_opr",
+ type: "string",
+ description:
+ "A string representing the and|or operator for topic0 and topic2. " <>
+ "Required if topic0 and topic2 is used. Available values: and, or"
+ },
+ %{
+ key: "topic0_3_opr",
+ type: "string",
+ description:
+ "A string representing the and|or operator for topic0 and topic3. " <>
+ "Required if topic0 and topic3 is used. Available values: and, or"
+ },
+ %{
+ key: "topic1_2_opr",
+ type: "string",
+ description:
+ "A string representing the and|or operator for topic1 and topic2. " <>
+ "Required if topic1 and topic2 is used. Available values: and, or"
+ },
+ %{
+ key: "topic1_3_opr",
+ type: "string",
+ description:
+ "A string representing the and|or operator for topic1 and topic3. " <>
+ "Required if topic1 and topic3 is used. Available values: and, or"
+ },
+ %{
+ key: "topic2_3_opr",
+ type: "string",
+ description:
+ "A string representing the and|or operator for topic2 and topic3. " <>
+ "Required if topic2 and topic3 is used. Available values: and, or"
+ }
+ ],
+ responses: [
+ %{
+ code: "200",
+ description: "successful operation",
+ example_value: Jason.encode!(@logs_getlogs_example_value),
+ model: %{
+ name: "Result",
+ fields: %{
+ status: @status_type,
+ message: @message_type,
+ result: %{
+ type: "array",
+ array_type: @log
+ }
+ }
+ }
+ },
+ %{
+ code: "200",
+ description: "error",
+ example_value: Jason.encode!(@logs_getlogs_example_value_error)
+ }
+ ]
+ }
+
@account_module %{
name: "account",
actions: [
@@ -317,7 +507,12 @@ defmodule BlockScoutWeb.Etherscan do
]
}
- @documentation [@account_module]
+ @logs_module %{
+ name: "logs",
+ actions: [@logs_getlogs_action]
+ }
+
+ @documentation [@account_module, @logs_module]
def get_documentation do
@documentation
diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex
index 0e3d2e9fb9..e282e5c0e9 100644
--- a/apps/block_scout_web/lib/block_scout_web/router.ex
+++ b/apps/block_scout_web/lib/block_scout_web/router.ex
@@ -30,7 +30,8 @@ defmodule BlockScoutWeb.Router do
forward("/", RPCTranslator, %{
"block" => RPC.BlockController,
- "account" => RPC.AddressController
+ "account" => RPC.AddressController,
+ "logs" => RPC.LogsController
})
end
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/api_docs/_module_card.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/api_docs/_module_card.html.eex
index ab12a78785..5c1609449c 100644
--- a/apps/block_scout_web/lib/block_scout_web/templates/api_docs/_module_card.html.eex
+++ b/apps/block_scout_web/lib/block_scout_web/templates/api_docs/_module_card.html.eex
@@ -1,6 +1,6 @@
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex
new file mode 100644
index 0000000000..69ffb7300c
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex
@@ -0,0 +1,49 @@
+defmodule BlockScoutWeb.API.RPC.LogsView do
+ use BlockScoutWeb, :view
+
+ alias BlockScoutWeb.API.RPC.RPCView
+
+ def render("getlogs.json", %{logs: logs}) do
+ data = Enum.map(logs, &prepare_log/1)
+ RPCView.render("show.json", data: data)
+ end
+
+ def render("error.json", assigns) do
+ RPCView.render("error.json", assigns)
+ end
+
+ defp prepare_log(log) do
+ %{
+ "address" => "#{log.address_hash}",
+ "topics" => get_topics(log),
+ "data" => "#{log.data}",
+ "blockNumber" => integer_to_hex(log.block_number),
+ "timeStamp" => datetime_to_hex(log.block_timestamp),
+ "gasPrice" => decimal_to_hex(log.gas_price.value),
+ "gasUsed" => decimal_to_hex(log.gas_used),
+ "logIndex" => integer_to_hex(log.index),
+ "transactionHash" => "#{log.transaction_hash}",
+ "transactionIndex" => integer_to_hex(log.transaction_index)
+ }
+ end
+
+ defp get_topics(log) do
+ log
+ |> Map.take([:first_topic, :second_topic, :third_topic, :fourth_topic])
+ |> Map.values()
+ end
+
+ defp integer_to_hex(integer), do: Integer.to_string(integer, 16)
+
+ defp decimal_to_hex(decimal) do
+ decimal
+ |> Decimal.to_integer()
+ |> integer_to_hex()
+ end
+
+ defp datetime_to_hex(datetime) do
+ datetime
+ |> DateTime.to_unix()
+ |> integer_to_hex()
+ end
+end
diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs
new file mode 100644
index 0000000000..e4e88f6ca4
--- /dev/null
+++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs
@@ -0,0 +1,771 @@
+defmodule BlockScoutWeb.API.RPC.LogsControllerTest do
+ use BlockScoutWeb.ConnCase
+
+ alias BlockScoutWeb.API.RPC.LogsController
+ alias Explorer.Chain.Transaction
+
+ describe "getLogs" do
+ test "without fromBlock, toBlock, address, and topic{x}", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs"
+ }
+
+ expected_message = "Required query parameters missing: fromBlock, toBlock, address and/or topic{x}"
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] == expected_message
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "without fromBlock", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "toBlock" => "10",
+ "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
+ }
+
+ expected_message = "Required query parameters missing: fromBlock"
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] == expected_message
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "without toBlock", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
+ }
+
+ expected_message = "Required query parameters missing: toBlock"
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] == expected_message
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "without address and topic{x}", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "10"
+ }
+
+ expected_message = "Required query parameters missing: address and/or topic{x}"
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] == expected_message
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "without topic{x}_{x}_opr", %{conn: conn} do
+ conditions = %{
+ ["topic0", "topic1"] => "topic0_1_opr",
+ ["topic0", "topic2"] => "topic0_2_opr",
+ ["topic0", "topic3"] => "topic0_3_opr",
+ ["topic1", "topic2"] => "topic1_2_opr",
+ ["topic1", "topic3"] => "topic1_3_opr",
+ ["topic2", "topic3"] => "topic2_3_opr"
+ }
+
+ for {[key1, key2], expectation} <- conditions do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "10",
+ key1 => "some topic",
+ key2 => "some other topic"
+ }
+
+ expected_message = "Required query parameters missing: #{expectation}"
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] == expected_message
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+ end
+
+ test "without multiple topic{x}_{x}_opr", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "10",
+ "topic0" => "some topic",
+ "topic1" => "some other topic",
+ "topic2" => "some extra topic",
+ "topic3" => "some different topic"
+ }
+
+ expected_message =
+ "Required query parameters missing: " <>
+ "topic0_1_opr, topic0_2_opr, topic0_3_opr, topic1_2_opr, topic1_3_opr, topic2_3_opr"
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] == expected_message
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "with invalid fromBlock", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "invalid",
+ "toBlock" => "10",
+ "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] =~ "Invalid fromBlock format"
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "with invalid toBlock", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "invalid",
+ "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] =~ "Invalid toBlock format"
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "with an invalid address hash", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "10",
+ "address" => "badhash"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] =~ "Invalid address format"
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+
+ test "with invalid topic{x}_{x}_opr", %{conn: conn} do
+ conditions = %{
+ ["topic0", "topic1"] => "topic0_1_opr",
+ ["topic0", "topic2"] => "topic0_2_opr",
+ ["topic0", "topic3"] => "topic0_3_opr",
+ ["topic1", "topic2"] => "topic1_2_opr",
+ ["topic1", "topic3"] => "topic1_3_opr",
+ ["topic2", "topic3"] => "topic2_3_opr"
+ }
+
+ for {[key1, key2], expectation} <- conditions do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "10",
+ key1 => "some topic",
+ key2 => "some other topic",
+ expectation => "invalid"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["message"] =~ "Invalid #{expectation} format"
+ assert response["status"] == "0"
+ assert Map.has_key?(response, "result")
+ refute response["result"]
+ end
+ end
+
+ test "with an address that doesn't exist", %{conn: conn} do
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "5",
+ "toBlock" => "10",
+ "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["result"] == []
+ assert response["status"] == "0"
+ assert response["message"] == "No logs found"
+ end
+
+ test "with a valid contract address", %{conn: conn} do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log = insert(:log, address: contract_address, transaction: transaction)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{block.number}",
+ "toBlock" => "#{block.number}",
+ "address" => "#{contract_address.hash}"
+ }
+
+ expected_result = [
+ %{
+ "address" => "#{contract_address.hash}",
+ "topics" => get_topics(log),
+ "data" => "#{log.data}",
+ "blockNumber" => integer_to_hex(transaction.block_number),
+ "timeStamp" => datetime_to_hex(block.timestamp),
+ "gasPrice" => decimal_to_hex(transaction.gas_price.value),
+ "gasUsed" => decimal_to_hex(transaction.gas_used),
+ "logIndex" => integer_to_hex(log.index),
+ "transactionHash" => "#{transaction.hash}",
+ "transactionIndex" => integer_to_hex(transaction.index)
+ }
+ ]
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["result"] == expected_result
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+ end
+
+ test "ignores logs with block below fromBlock", %{conn: conn} do
+ first_block = insert(:block)
+ second_block = insert(:block)
+
+ contract_address = insert(:contract_address)
+
+ transaction_block1 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(first_block)
+
+ transaction_block2 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(second_block)
+
+ insert(:log, address: contract_address, transaction: transaction_block1)
+ insert(:log, address: contract_address, transaction: transaction_block2)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{second_block.number}",
+ "toBlock" => "#{second_block.number}",
+ "address" => "#{contract_address.hash}"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+
+ [found_log] = response["result"]
+
+ assert found_log["blockNumber"] == integer_to_hex(second_block.number)
+ assert found_log["transactionHash"] == "#{transaction_block2.hash}"
+ end
+
+ test "ignores logs with block above toBlock", %{conn: conn} do
+ first_block = insert(:block)
+ second_block = insert(:block)
+
+ contract_address = insert(:contract_address)
+
+ transaction_block1 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(first_block)
+
+ transaction_block2 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(second_block)
+
+ insert(:log, address: contract_address, transaction: transaction_block1)
+ insert(:log, address: contract_address, transaction: transaction_block2)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{first_block.number}",
+ "toBlock" => "#{first_block.number}",
+ "address" => "#{contract_address.hash}"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+
+ [found_log] = response["result"]
+
+ assert found_log["blockNumber"] == integer_to_hex(first_block.number)
+ assert found_log["transactionHash"] == "#{transaction_block1.hash}"
+ end
+
+ test "with a valid topic{x}", %{conn: conn} do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some other topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ _log2 = insert(:log, log2_details)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{block.number}",
+ "toBlock" => "#{block.number}",
+ "topic0" => log1.first_topic
+ }
+
+ expected_result = [
+ %{
+ "address" => "#{contract_address.hash}",
+ "topics" => get_topics(log1),
+ "data" => "#{log1.data}",
+ "blockNumber" => integer_to_hex(transaction.block_number),
+ "timeStamp" => datetime_to_hex(block.timestamp),
+ "gasPrice" => decimal_to_hex(transaction.gas_price.value),
+ "gasUsed" => decimal_to_hex(transaction.gas_used),
+ "logIndex" => integer_to_hex(log1.index),
+ "transactionHash" => "#{transaction.hash}",
+ "transactionIndex" => integer_to_hex(transaction.index)
+ }
+ ]
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert response["result"] == expected_result
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+ end
+
+ test "with a topic{x} AND another", %{conn: conn} do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some other topic",
+ second_topic: "some other second topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ _log2 = insert(:log, log2_details)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{block.number}",
+ "toBlock" => "#{block.number}",
+ "topic0" => log1.first_topic,
+ "topic1" => log1.second_topic,
+ "topic0_1_opr" => "and"
+ }
+
+ assert response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert [found_log] = response["result"]
+ assert found_log["logIndex"] == integer_to_hex(log1.index)
+ assert found_log["topics"] == get_topics(log1)
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+ end
+
+ test "with a topic{x} OR another", %{conn: conn} do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some other topic",
+ second_topic: "some other second topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{block.number}",
+ "toBlock" => "#{block.number}",
+ "topic0" => log1.first_topic,
+ "topic1" => log2.second_topic,
+ "topic0_1_opr" => "or"
+ }
+
+ assert %{"result" => result} =
+ response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert length(result) == 2
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+ end
+
+ test "with all available 'topic{x}'s and 'topic{x}_{x}_opr's", %{conn: conn} do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic",
+ fourth_topic: "some fourth topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic",
+ fourth_topic: "some other fourth topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+
+ params = %{
+ "module" => "logs",
+ "action" => "getLogs",
+ "fromBlock" => "#{block.number}",
+ "toBlock" => "#{block.number}",
+ "topic0" => log1.first_topic,
+ "topic1" => log1.second_topic,
+ "topic2" => log1.third_topic,
+ "topic3" => log2.fourth_topic,
+ "topic0_1_opr" => "and",
+ "topic0_2_opr" => "and",
+ "topic0_3_opr" => "or",
+ "topic1_2_opr" => "and",
+ "topic1_3_opr" => "or",
+ "topic2_3_opr" => "or"
+ }
+
+ assert %{"result" => result} =
+ response =
+ conn
+ |> get("/api", params)
+ |> json_response(200)
+
+ assert length(result) == 2
+ assert response["status"] == "1"
+ assert response["message"] == "OK"
+ end
+ end
+
+ describe "fetch_required_params/1" do
+ test "without any required params" do
+ params = %{}
+
+ {_, {:error, missing_params}} = LogsController.fetch_required_params(params)
+
+ assert missing_params == ["fromBlock", "toBlock", "address and/or topic{x}"]
+ end
+
+ test "without fromBlock" do
+ params = %{
+ "toBlock" => "5",
+ "address" => "some address"
+ }
+
+ {_, {:error, [missing_param]}} = LogsController.fetch_required_params(params)
+
+ assert missing_param == "fromBlock"
+ end
+
+ test "without toBlock" do
+ params = %{
+ "fromBlock" => "5",
+ "address" => "some address"
+ }
+
+ {_, {:error, [missing_param]}} = LogsController.fetch_required_params(params)
+
+ assert missing_param == "toBlock"
+ end
+
+ test "without fromBlock or toBlock" do
+ params = %{
+ "address" => "some address"
+ }
+
+ {_, {:error, missing_params}} = LogsController.fetch_required_params(params)
+
+ assert missing_params == ["fromBlock", "toBlock"]
+ end
+
+ test "without address or topic{x}" do
+ params = %{
+ "toBlock" => "5",
+ "fromBlock" => "5"
+ }
+
+ {_, {:error, [missing_param]}} = LogsController.fetch_required_params(params)
+
+ assert missing_param == "address and/or topic{x}"
+ end
+
+ test "with address" do
+ params = %{
+ "fromBlock" => "5",
+ "toBlock" => "5",
+ "address" => "some address"
+ }
+
+ {_, {:ok, fetched_params}} = LogsController.fetch_required_params(params)
+
+ assert fetched_params == params
+ end
+
+ test "with topic{x}" do
+ for topic <- ["topic0", "topic1", "topic2", "topic3"] do
+ params = %{
+ "fromBlock" => "5",
+ "toBlock" => "5",
+ topic => "some topic"
+ }
+
+ {_, {:ok, fetched_params}} = LogsController.fetch_required_params(params)
+
+ assert fetched_params == params
+ end
+ end
+
+ test "with address and topic{x}" do
+ params = %{
+ "fromBlock" => "5",
+ "toBlock" => "5",
+ "address" => "some address",
+ "topic0" => "some topic"
+ }
+
+ {_, {:ok, fetched_params}} = LogsController.fetch_required_params(params)
+
+ assert fetched_params == params
+ end
+ end
+
+ describe "to_valid_format/1" do
+ test "with invalid fromBlock" do
+ params = %{"fromBlock" => "invalid"}
+
+ assert {_, {:error, "fromBlock"}} = LogsController.to_valid_format(params)
+ end
+
+ test "with invalid toBlock" do
+ params = %{
+ "fromBlock" => "5",
+ "toBlock" => "invalid"
+ }
+
+ assert {_, {:error, "toBlock"}} = LogsController.to_valid_format(params)
+ end
+
+ test "with invalid address" do
+ params = %{
+ "fromBlock" => "5",
+ "toBlock" => "10",
+ "address" => "invalid"
+ }
+
+ assert {_, {:error, "address"}} = LogsController.to_valid_format(params)
+ end
+
+ test "address_hash returns as nil when missing" do
+ params = %{
+ "fromBlock" => "5",
+ "toBlock" => "10"
+ }
+
+ assert {_, {:ok, validated_params}} = LogsController.to_valid_format(params)
+ refute validated_params.address_hash
+ end
+
+ test "fromBlock and toBlock support use of 'latest'" do
+ params = %{
+ "fromBlock" => "latest",
+ "toBlock" => "latest"
+ }
+
+ # Without any blocks in the db we want to return {:error, :not_found}
+ assert {_, {:error, :not_found}} = LogsController.to_valid_format(params)
+
+ # We insert a block, try again, and assert 'latest' points to the latest
+ # block number.
+ insert(:block)
+ {:ok, max_block_number} = Explorer.Chain.max_block_number()
+
+ assert {_, {:ok, validated_params}} = LogsController.to_valid_format(params)
+ assert validated_params.from_block == max_block_number
+ assert validated_params.to_block == max_block_number
+ end
+ end
+
+ defp get_topics(log) do
+ log
+ |> Map.take([:first_topic, :second_topic, :third_topic, :fourth_topic])
+ |> Map.values()
+ end
+
+ defp integer_to_hex(integer), do: Integer.to_string(integer, 16)
+
+ defp decimal_to_hex(decimal) do
+ decimal
+ |> Decimal.to_integer()
+ |> integer_to_hex()
+ end
+
+ defp datetime_to_hex(datetime) do
+ datetime
+ |> DateTime.to_unix()
+ |> integer_to_hex()
+ end
+end
diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex
index e8cdd3ea18..31f3ddfee6 100644
--- a/apps/explorer/lib/explorer/etherscan.ex
+++ b/apps/explorer/lib/explorer/etherscan.ex
@@ -5,6 +5,7 @@ defmodule Explorer.Etherscan do
import Ecto.Query, only: [from: 2, where: 3]
+ alias Explorer.Etherscan.Logs
alias Explorer.{Repo, Chain}
alias Explorer.Chain.{Hash, Transaction}
@@ -99,4 +100,36 @@ defmodule Explorer.Etherscan do
end
defp offset(options), do: (options.page_number - 1) * options.page_size
+
+ @doc """
+ Gets a list of logs that meet the criteria in a given filter map.
+
+ Required filter parameters:
+
+ * `from_block`
+ * `to_block`
+ * `address_hash` and/or `{x}_topic`
+ * When multiple `{x}_topic` params are provided, then the corresponding
+ `topic{x}_{x}_opr` param is required. For example, if "first_topic" and
+ "second_topic" are provided, then "topic0_1_opr" is required.
+
+ Supported `{x}_topic`s:
+
+ * first_topic
+ * second_topic
+ * third_topic
+ * fourth_topic
+
+ Supported `topic{x}_{x}_opr`s:
+
+ * topic0_1_opr
+ * topic0_2_opr
+ * topic0_3_opr
+ * topic1_2_opr
+ * topic1_3_opr
+ * topic2_3_opr
+
+ """
+ @spec list_logs(map()) :: [map()]
+ def list_logs(filter), do: Logs.list_logs(filter)
end
diff --git a/apps/explorer/lib/explorer/etherscan/logs.ex b/apps/explorer/lib/explorer/etherscan/logs.ex
new file mode 100644
index 0000000000..50f3e5cf37
--- /dev/null
+++ b/apps/explorer/lib/explorer/etherscan/logs.ex
@@ -0,0 +1,150 @@
+defmodule Explorer.Etherscan.Logs do
+ @moduledoc """
+ This module contains functions for working with logs, as they pertain to the
+ `Explorer.Etherscan` context.
+
+ """
+
+ import Ecto.Query, only: [from: 2, where: 3]
+
+ alias Explorer.Repo
+ alias Explorer.Chain.Log
+
+ @base_filter %{
+ from_block: nil,
+ to_block: nil,
+ address_hash: nil,
+ first_topic: nil,
+ second_topic: nil,
+ third_topic: nil,
+ fourth_topic: nil,
+ topic0_1_opr: nil,
+ topic0_2_opr: nil,
+ topic0_3_opr: nil,
+ topic1_2_opr: nil,
+ topic1_3_opr: nil,
+ topic2_3_opr: nil
+ }
+
+ @log_fields [
+ :data,
+ :first_topic,
+ :second_topic,
+ :third_topic,
+ :fourth_topic,
+ :index,
+ :address_hash,
+ :transaction_hash
+ ]
+
+ @doc """
+ Gets a list of logs that meet the criteria in a given filter map.
+
+ Required filter parameters:
+
+ * `from_block`
+ * `to_block`
+ * `address_hash` and/or `{x}_topic`
+ * When multiple `{x}_topic` params are provided, then the corresponding
+ `topic{x}_{x}_opr` param is required. For example, if "first_topic" and
+ "second_topic" are provided, then "topic0_1_opr" is required.
+
+ Supported `{x}_topic`s:
+
+ * first_topic
+ * second_topic
+ * third_topic
+ * fourth_topic
+
+ Supported `topic{x}_{x}_opr`s:
+
+ * topic0_1_opr
+ * topic0_2_opr
+ * topic0_3_opr
+ * topic1_2_opr
+ * topic1_3_opr
+ * topic2_3_opr
+
+ """
+ @spec list_logs(map()) :: [map()]
+ def list_logs(filter) do
+ prepared_filter = Map.merge(@base_filter, filter)
+
+ query =
+ from(
+ l in Log,
+ inner_join: t in assoc(l, :transaction),
+ inner_join: b in assoc(t, :block),
+ where: b.number >= ^prepared_filter.from_block,
+ where: b.number <= ^prepared_filter.to_block,
+ order_by: b.number,
+ limit: 1_000,
+ select:
+ merge(map(l, ^@log_fields), %{
+ gas_price: t.gas_price,
+ gas_used: t.gas_used,
+ transaction_index: t.index,
+ block_number: b.number,
+ block_timestamp: b.timestamp
+ })
+ )
+
+ query
+ |> where_address_match(prepared_filter)
+ |> where_topic_match(prepared_filter)
+ |> Repo.all()
+ end
+
+ @topics [
+ :first_topic,
+ :second_topic,
+ :third_topic,
+ :fourth_topic
+ ]
+
+ @topic_operations %{
+ topic0_1_opr: {:first_topic, :second_topic},
+ topic0_2_opr: {:first_topic, :third_topic},
+ topic0_3_opr: {:first_topic, :fourth_topic},
+ topic1_2_opr: {:second_topic, :third_topic},
+ topic1_3_opr: {:second_topic, :fourth_topic},
+ topic2_3_opr: {:third_topic, :fourth_topic}
+ }
+
+ defp where_address_match(query, %{address_hash: address_hash}) when not is_nil(address_hash) do
+ where(query, [l], l.address_hash == ^address_hash)
+ end
+
+ defp where_address_match(query, _), do: query
+
+ defp where_topic_match(query, filter) do
+ case Enum.filter(@topics, &filter[&1]) do
+ [] ->
+ query
+
+ [topic] ->
+ where(query, [l], field(l, ^topic) == ^filter[topic])
+
+ _ ->
+ where_multiple_topics_match(query, filter)
+ end
+ end
+
+ defp where_multiple_topics_match(query, filter) do
+ Enum.reduce(Map.keys(@topic_operations), query, fn topic_operation, acc_query ->
+ where_multiple_topics_match(acc_query, filter, topic_operation, filter[topic_operation])
+ end)
+ end
+
+ defp where_multiple_topics_match(query, filter, topic_operation, "and") do
+ {topic_a, topic_b} = @topic_operations[topic_operation]
+ where(query, [l], field(l, ^topic_a) == ^filter[topic_a] and field(l, ^topic_b) == ^filter[topic_b])
+ end
+
+ defp where_multiple_topics_match(query, filter, topic_operation, "or") do
+ {topic_a, topic_b} = @topic_operations[topic_operation]
+ where(query, [l], field(l, ^topic_a) == ^filter[topic_a] or field(l, ^topic_b) == ^filter[topic_b])
+ end
+
+ defp where_multiple_topics_match(query, _, _, _), do: query
+end
diff --git a/apps/explorer/test/explorer/etherscan/logs_test.exs b/apps/explorer/test/explorer/etherscan/logs_test.exs
new file mode 100644
index 0000000000..65c62175f6
--- /dev/null
+++ b/apps/explorer/test/explorer/etherscan/logs_test.exs
@@ -0,0 +1,630 @@
+defmodule Explorer.Etherscan.LogsTest do
+ use Explorer.DataCase
+
+ import Explorer.Factory
+
+ alias Explorer.Etherscan.Logs
+ alias Explorer.Chain.Transaction
+
+ describe "list_logs/1" do
+ test "with empty db" do
+ contract_address = build(:contract_address)
+
+ filter = %{
+ from_block: 0,
+ to_block: 9999,
+ address_hash: contract_address.hash
+ }
+
+ assert Logs.list_logs(filter) == []
+ end
+
+ test "with address with zero logs" do
+ contract_address = insert(:contract_address)
+
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash
+ }
+
+ assert Logs.list_logs(filter) == []
+ end
+
+ test "with address with one log response includes all required information" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log = insert(:log, address: contract_address, transaction: transaction)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.data == log.data
+ assert found_log.first_topic == log.first_topic
+ assert found_log.second_topic == log.second_topic
+ assert found_log.third_topic == log.third_topic
+ assert found_log.fourth_topic == log.fourth_topic
+ assert found_log.index == log.index
+ assert found_log.address_hash == log.address_hash
+ assert found_log.transaction_hash == log.transaction_hash
+ assert found_log.gas_price == transaction.gas_price
+ assert found_log.gas_used == transaction.gas_used
+ assert found_log.transaction_index == transaction.index
+ assert found_log.block_number == block.number
+ assert found_log.block_timestamp == block.timestamp
+ end
+
+ test "with address with two logs" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ insert_list(2, :log, address: contract_address, transaction: transaction)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash
+ }
+
+ found_logs = Logs.list_logs(filter)
+
+ assert length(found_logs) == 2
+ end
+
+ test "ignores logs with block below fromBlock" do
+ first_block = insert(:block)
+ second_block = insert(:block)
+
+ contract_address = insert(:contract_address)
+
+ transaction_block1 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(first_block)
+
+ transaction_block2 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(second_block)
+
+ insert(:log, address: contract_address, transaction: transaction_block1)
+ insert(:log, address: contract_address, transaction: transaction_block2)
+
+ filter = %{
+ from_block: second_block.number,
+ to_block: second_block.number,
+ address_hash: contract_address.hash
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.block_number == second_block.number
+ assert found_log.transaction_hash == transaction_block2.hash
+ end
+
+ test "ignores logs with block above toBlock" do
+ first_block = insert(:block)
+ second_block = insert(:block)
+
+ contract_address = insert(:contract_address)
+
+ transaction_block1 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(first_block)
+
+ transaction_block2 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(second_block)
+
+ insert(:log, address: contract_address, transaction: transaction_block1)
+ insert(:log, address: contract_address, transaction: transaction_block2)
+
+ filter = %{
+ from_block: first_block.number,
+ to_block: first_block.number,
+ address_hash: contract_address.hash
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.block_number == first_block.number
+ assert found_log.transaction_hash == transaction_block1.hash
+ end
+
+ test "with a valid topic{x}" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some other topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ _log2 = insert(:log, log2_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ first_topic: log1.first_topic
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.first_topic == log1.first_topic
+ assert found_log.index == log1.index
+ end
+
+ test "with a valid topic{x} AND another" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic",
+ second_topic: "some second topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic",
+ second_topic: "some OTHER second topic"
+ ]
+
+ _log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ first_topic: log2.first_topic,
+ second_topic: log2.second_topic,
+ topic0_1_opr: "and"
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.second_topic == log2.second_topic
+ assert found_log.first_topic == log2.first_topic
+ assert found_log.index == log2.index
+ end
+
+ test "with a valid topic{x} OR another" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic",
+ second_topic: "some second topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some OTHER first topic",
+ second_topic: "some OTHER second topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ first_topic: log1.first_topic,
+ second_topic: log2.second_topic,
+ topic0_1_opr: "or"
+ }
+
+ found_logs = Logs.list_logs(filter)
+
+ assert length(found_logs) == 2
+ end
+
+ test "with address and topic{x}" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some OTHER first topic"
+ ]
+
+ _log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash,
+ first_topic: log2.first_topic
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.index == log2.index
+ assert found_log.first_topic == log2.first_topic
+ end
+
+ test "with address and two topic{x}s" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic",
+ second_topic: "some second topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some OTHER first topic",
+ second_topic: "some OTHER second topic"
+ ]
+
+ _log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash,
+ first_topic: log2.first_topic,
+ second_topic: log2.second_topic,
+ topic0_1_opr: "and"
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.index == log2.index
+ assert found_log.first_topic == log2.first_topic
+ end
+
+ test "with address and three topic{x}s with AND operator" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some OTHER first topic",
+ second_topic: "some OTHER second topic",
+ third_topic: "some OTHER third topic"
+ ]
+
+ log3_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some ALT first topic",
+ second_topic: "some ALT second topic",
+ third_topic: "some ALT third topic"
+ ]
+
+ _log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+ _log3 = insert(:log, log3_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash,
+ first_topic: log2.first_topic,
+ second_topic: log2.second_topic,
+ third_topic: log2.third_topic,
+ topic0_1_opr: "and",
+ topic0_2_opr: "and",
+ topic1_2_opr: "and"
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.index == log2.index
+ assert found_log.first_topic == log2.first_topic
+ assert found_log.second_topic == log2.second_topic
+ assert found_log.third_topic == log2.third_topic
+ end
+
+ test "with address and three topic{x}s with OR operator" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some first topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some OTHER first topic",
+ second_topic: "some OTHER second topic",
+ third_topic: "some OTHER third topic"
+ ]
+
+ log3_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some ALT first topic",
+ second_topic: "some ALT second topic",
+ third_topic: "some ALT third topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+ _log3 = insert(:log, log3_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash,
+ first_topic: log1.first_topic,
+ second_topic: log2.second_topic,
+ third_topic: log2.third_topic,
+ topic0_1_opr: "or",
+ topic0_2_opr: "or",
+ topic1_2_opr: "or"
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.index == log2.index
+ assert found_log.first_topic == log2.first_topic
+ assert found_log.second_topic == log2.second_topic
+ assert found_log.third_topic == log2.third_topic
+ end
+
+ test "three topic{x}s with OR and AND operator" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some OTHER second topic",
+ third_topic: "some third topic"
+ ]
+
+ log3_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+ _log3 = insert(:log, log3_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash,
+ first_topic: log1.first_topic,
+ second_topic: log2.second_topic,
+ third_topic: log2.third_topic,
+ topic0_1_opr: "or",
+ topic0_2_opr: "or",
+ topic1_2_opr: "and"
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.index == log2.index
+ assert found_log.first_topic == log2.first_topic
+ assert found_log.second_topic == log2.second_topic
+ assert found_log.third_topic == log2.third_topic
+ end
+
+ test "four topic{x}s with all possible operators" do
+ contract_address = insert(:contract_address)
+
+ transaction =
+ %Transaction{block: block} =
+ :transaction
+ |> insert()
+ |> with_block()
+
+ log1_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic"
+ ]
+
+ log2_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some OTHER topic",
+ second_topic: "some OTHER second topic",
+ third_topic: "some OTHER third topic",
+ fourth_topic: "some fourth topic"
+ ]
+
+ log3_details = [
+ address: contract_address,
+ transaction: transaction,
+ first_topic: "some topic",
+ second_topic: "some second topic",
+ third_topic: "some third topic",
+ fourth_topic: "some fourth topic"
+ ]
+
+ log1 = insert(:log, log1_details)
+ log2 = insert(:log, log2_details)
+ _log3 = insert(:log, log3_details)
+
+ filter = %{
+ from_block: block.number,
+ to_block: block.number,
+ address_hash: contract_address.hash,
+ first_topic: log1.first_topic,
+ second_topic: log2.second_topic,
+ third_topic: log2.third_topic,
+ fourth_topic: log2.fourth_topic,
+ topic0_1_opr: "or",
+ topic0_2_opr: "or",
+ topic0_3_opr: "or",
+ topic1_2_opr: "and",
+ topic1_3_opr: "and",
+ topic2_3_opr: "and"
+ }
+
+ [found_log] = Logs.list_logs(filter)
+
+ assert found_log.index == log2.index
+ assert found_log.first_topic == log2.first_topic
+ assert found_log.second_topic == log2.second_topic
+ assert found_log.third_topic == log2.third_topic
+ end
+
+ test "returned logs are sorted by block" do
+ first_block = insert(:block)
+ second_block = insert(:block)
+ third_block = insert(:block)
+
+ contract_address = insert(:contract_address)
+
+ transaction_block1 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(first_block)
+
+ transaction_block2 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(second_block)
+
+ transaction_block3 =
+ %Transaction{} =
+ :transaction
+ |> insert()
+ |> with_block(third_block)
+
+ insert(:log, address: contract_address, transaction: transaction_block3)
+ insert(:log, address: contract_address, transaction: transaction_block1)
+ insert(:log, address: contract_address, transaction: transaction_block2)
+
+ filter = %{
+ from_block: first_block.number,
+ to_block: third_block.number,
+ address_hash: contract_address.hash
+ }
+
+ found_logs = Logs.list_logs(filter)
+
+ block_number_order = Enum.map(found_logs, & &1.block_number)
+
+ assert block_number_order == Enum.sort(block_number_order)
+ end
+ end
+end