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 @@

- <%= "#{String.capitalize(@module.name)}s" %> + <%= "#{String.capitalize(@module.name)}" %> ?module=<%= @module.name %>

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