diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index afd51cc5d7..a480a8ee23 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -3,23 +3,41 @@ defmodule Explorer.Etherscan do The etherscan context. """ - import Ecto.Query, - only: [ - from: 2 - ] + import Ecto.Query, only: [from: 2, where: 3] alias Explorer.{Repo, Chain} alias Explorer.Chain.{Hash, Transaction} + @default_options %{ + order_by_direction: :asc, + page_number: 1, + page_size: 10_000, + start_block: nil, + end_block: nil + } + + @doc """ + Returns the maximum allowed page size number. + + """ + @spec page_size_max :: pos_integer() + def page_size_max do + @default_options.page_size + end + @doc """ Gets a list of transactions for a given `t:Explorer.Chain.Hash.Address`. """ @spec list_transactions(Hash.Address.t()) :: [map()] - def list_transactions(%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash) do + def list_transactions( + %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash, + options \\ @default_options + ) do case Chain.max_block_number() do {:ok, max_block_number} -> - list_transactions(address_hash, max_block_number) + merged_options = Map.merge(@default_options, options) + list_transactions(address_hash, max_block_number, merged_options) _ -> [] @@ -43,7 +61,7 @@ defmodule Explorer.Etherscan do :gas_used ] - defp list_transactions(address_hash, max_block_number) do + defp list_transactions(address_hash, max_block_number, options) do query = from( t in Transaction, @@ -52,8 +70,9 @@ defmodule Explorer.Etherscan do where: t.to_address_hash == ^address_hash, or_where: t.from_address_hash == ^address_hash, or_where: it.transaction_hash == t.hash and it.type == ^"create", - order_by: [asc: t.block_number], - limit: 10_000, + order_by: [{^options.order_by_direction, t.block_number}], + limit: ^options.page_size, + offset: ^offset(options), select: merge(map(t, ^@transaction_fields), %{ block_timestamp: b.timestamp, @@ -62,6 +81,23 @@ defmodule Explorer.Etherscan do }) ) - Repo.all(query) + query + |> where_start_block_match(options) + |> where_end_block_match(options) + |> Repo.all() end + + defp where_start_block_match(query, %{start_block: nil}), do: query + + defp where_start_block_match(query, %{start_block: start_block}) do + where(query, [t], t.block_number >= ^start_block) + end + + defp where_end_block_match(query, %{end_block: nil}), do: query + + defp where_end_block_match(query, %{end_block: end_block}) do + where(query, [t], t.block_number <= ^end_block) + end + + defp offset(options), do: (options.page_number - 1) * options.page_size end diff --git a/apps/explorer/test/explorer/etherscan_test.exs b/apps/explorer/test/explorer/etherscan_test.exs index 322cc9625e..99828632ec 100644 --- a/apps/explorer/test/explorer/etherscan_test.exs +++ b/apps/explorer/test/explorer/etherscan_test.exs @@ -148,7 +148,7 @@ defmodule Explorer.EtherscanTest do assert found_transaction.block_timestamp == block.timestamp end - test "orders transactions by block, in ascending order" do + test "orders transactions by block, in ascending order (default)" do first_block = insert(:block) second_block = insert(:block) address = insert(:address) @@ -171,5 +171,169 @@ defmodule Explorer.EtherscanTest do assert block_numbers_order == Enum.sort(block_numbers_order) end + + test "orders transactions by block, in descending order" do + first_block = insert(:block) + second_block = insert(:block) + address = insert(:address) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block() + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + options = %{order_by_direction: :desc} + + found_transactions = Etherscan.list_transactions(address.hash, options) + + block_numbers_order = Enum.map(found_transactions, & &1.block_number) + + assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 >= &2)) + end + + test "with page_size and page_number options" do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + second_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + third_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + first_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + options = %{page_number: 1, page_size: 2} + + page1_transactions = Etherscan.list_transactions(address.hash, options) + + page1_hashes = Enum.map(page1_transactions, & &1.hash) + + assert length(page1_transactions) == 2 + + for transaction <- first_block_transactions do + assert transaction.hash in page1_hashes + end + + options = %{page_number: 2, page_size: 2} + + page2_transactions = Etherscan.list_transactions(address.hash, options) + + page2_hashes = Enum.map(page2_transactions, & &1.hash) + + assert length(page2_transactions) == 2 + + for transaction <- second_block_transactions do + assert transaction.hash in page2_hashes + end + + options = %{page_number: 3, page_size: 2} + + page3_transactions = Etherscan.list_transactions(address.hash, options) + + page3_hashes = Enum.map(page3_transactions, & &1.hash) + + assert length(page3_transactions) == 2 + + for transaction <- third_block_transactions do + assert transaction.hash in page3_hashes + end + + options = %{page_number: 4, page_size: 2} + + assert Etherscan.list_transactions(address.hash, options) == [] + end + + test "with start and end block options" do + blocks = [_, second_block, third_block, _] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + options = %{ + start_block: second_block.number, + end_block: third_block.number + } + + found_transactions = Etherscan.list_transactions(address.hash, options) + + expected_block_numbers = [second_block.number, third_block.number] + + assert length(found_transactions) == 4 + + for transaction <- found_transactions do + assert transaction.block_number in expected_block_numbers + end + end + + test "with start_block but no end_block option" do + blocks = [_, _, third_block, fourth_block] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + options = %{ + start_block: third_block.number + } + + found_transactions = Etherscan.list_transactions(address.hash, options) + + expected_block_numbers = [third_block.number, fourth_block.number] + + assert length(found_transactions) == 4 + + for transaction <- found_transactions do + assert transaction.block_number in expected_block_numbers + end + end + + test "with end_block but no start_block option" do + blocks = [first_block, second_block, _, _] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + options = %{ + end_block: second_block.number + } + + found_transactions = Etherscan.list_transactions(address.hash, options) + + expected_block_numbers = [first_block.number, second_block.number] + + assert length(found_transactions) == 4 + + for transaction <- found_transactions do + assert transaction.block_number in expected_block_numbers + end + end end end diff --git a/apps/explorer_web/lib/explorer_web/controllers/api/rpc/address_controller.ex b/apps/explorer_web/lib/explorer_web/controllers/api/rpc/address_controller.ex index 8217e46532..053ac68646 100644 --- a/apps/explorer_web/lib/explorer_web/controllers/api/rpc/address_controller.ex +++ b/apps/explorer_web/lib/explorer_web/controllers/api/rpc/address_controller.ex @@ -27,9 +27,11 @@ defmodule ExplorerWeb.API.RPC.AddressController do end def txlist(conn, params) do + options = optional_params(params) + with {:address_param, {:ok, address_param}} <- fetch_address(params), {:format, {:ok, address_hash}} <- to_address_hash(address_param), - {:ok, transactions} <- list_transactions(address_hash) do + {:ok, transactions} <- list_transactions(address_hash, options) do render(conn, :txlist, %{transactions: transactions}) else {:address_param, :error} -> @@ -110,8 +112,65 @@ defmodule ExplorerWeb.API.RPC.AddressController do {:format, Chain.string_to_address_hash(address_hash_string)} end - defp list_transactions(address_hash) do - case Etherscan.list_transactions(address_hash) do + defp optional_params(params) do + %{} + |> put_order_by_direction(params) + |> put_pagination_options(params) + |> put_start_block(params) + |> put_end_block(params) + end + + defp put_order_by_direction(options, params) do + case params do + %{"sort" => sort} when sort in ["asc", "desc"] -> + order_by_direction = String.to_existing_atom(sort) + Map.put(options, :order_by_direction, order_by_direction) + + _ -> + options + end + end + + defp put_pagination_options(options, params) do + with %{"page" => page, "offset" => offset} <- params, + {page_number, ""} when page_number > 0 <- Integer.parse(page), + {page_size, ""} when page_size > 0 <- Integer.parse(offset), + :ok <- validate_max_page_size(page_size) do + options + |> Map.put(:page_number, page_number) + |> Map.put(:page_size, page_size) + else + _ -> + options + end + end + + defp validate_max_page_size(page_size) do + if page_size <= Etherscan.page_size_max(), do: :ok, else: :error + end + + defp put_start_block(options, params) do + with %{"startblock" => startblock_param} <- params, + {start_block, ""} <- Integer.parse(startblock_param) do + Map.put(options, :start_block, start_block) + else + _ -> + options + end + end + + defp put_end_block(options, params) do + with %{"endblock" => endblock_param} <- params, + {end_block, ""} <- Integer.parse(endblock_param) do + Map.put(options, :end_block, end_block) + else + _ -> + options + end + end + + defp list_transactions(address_hash, options) do + case Etherscan.list_transactions(address_hash, options) do [] -> {:error, :not_found} transactions -> {:ok, transactions} end diff --git a/apps/explorer_web/test/explorer_web/controllers/api/rpc/address_controller_test.exs b/apps/explorer_web/test/explorer_web/controllers/api/rpc/address_controller_test.exs index 3c32984796..37ce1baff0 100644 --- a/apps/explorer_web/test/explorer_web/controllers/api/rpc/address_controller_test.exs +++ b/apps/explorer_web/test/explorer_web/controllers/api/rpc/address_controller_test.exs @@ -480,5 +480,481 @@ defmodule ExplorerWeb.API.RPC.AddressControllerTest do assert response["status"] == "1" assert response["message"] == "OK" end + + test "orders transactions by block, in ascending order", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "sort" => "asc" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + block_numbers_order = + Enum.map(response["result"], fn transaction -> + String.to_integer(transaction["blockNumber"]) + end) + + assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 <= &2)) + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "orders transactions by block, in descending order", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "sort" => "desc" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + block_numbers_order = + Enum.map(response["result"], fn transaction -> + String.to_integer(transaction["blockNumber"]) + end) + + assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 >= &2)) + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "ignores invalid sort option, defaults to ascending", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "sort" => "invalidsortoption" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + block_numbers_order = + Enum.map(response["result"], fn transaction -> + String.to_integer(transaction["blockNumber"]) + end) + + assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 <= &2)) + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "with valid pagination params", %{conn: conn} do + # To get paginated results on this endpoint Etherscan's docs say: + # + # "(To get paginated results use page= and offset=)" + + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + _second_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + _third_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + first_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + # page number + "page" => "1", + # page size + "offset" => "2" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + page1_hashes = Enum.map(response["result"], & &1["hash"]) + + assert length(response["result"]) == 2 + + for transaction <- first_block_transactions do + assert "#{transaction.hash}" in page1_hashes + end + + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "ignores pagination params when invalid", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + _second_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + _third_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + _first_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + # page number + "page" => "invalidpage", + # page size + "offset" => "invalidoffset" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 6 + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "ignores pagination params if page is less than 1", %{conn: conn} do + address = insert(:address) + + 6 + |> insert_list(:transaction, from_address: address) + |> with_block() + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + # page number + "page" => "0", + # page size + "offset" => "2" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 6 + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "ignores pagination params if offset is less than 1", %{conn: conn} do + address = insert(:address) + + 6 + |> insert_list(:transaction, from_address: address) + |> with_block() + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + # page number + "page" => "1", + # page size + "offset" => "0" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 6 + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "ignores pagination params if offset is over 10,000", %{conn: conn} do + address = insert(:address) + + 6 + |> insert_list(:transaction, from_address: address) + |> with_block() + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + # page number + "page" => "1", + # page size + "offset" => "10_500" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 6 + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "with page number with no results", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + address = insert(:address) + + _second_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + _third_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + _first_block_transactions = + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + # page number + "page" => "5", + # page size + "offset" => "2" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "0" + assert response["message"] == "No transactions found" + end + + test "with startblock and endblock params", %{conn: conn} do + blocks = [_, second_block, third_block, _] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "startblock" => "#{second_block.number}", + "endblock" => "#{third_block.number}" + } + + expected_block_numbers = [ + "#{second_block.number}", + "#{third_block.number}" + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 4 + + for transaction <- response["result"] do + assert transaction["blockNumber"] in expected_block_numbers + end + + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "with startblock but without endblock", %{conn: conn} do + blocks = [_, _, third_block, fourth_block] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "startblock" => "#{third_block.number}" + } + + expected_block_numbers = [ + "#{third_block.number}", + "#{fourth_block.number}" + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 4 + + for transaction <- response["result"] do + assert transaction["blockNumber"] in expected_block_numbers + end + + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "with endblock but without startblock", %{conn: conn} do + blocks = [first_block, second_block, _, _] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "endblock" => "#{second_block.number}" + } + + expected_block_numbers = [ + "#{first_block.number}", + "#{second_block.number}" + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 4 + + for transaction <- response["result"] do + assert transaction["blockNumber"] in expected_block_numbers + end + + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "ignores invalid startblock and endblock", %{conn: conn} do + blocks = [_, _, _, _] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "startblock" => "invalidstart", + "endblock" => "invalidend" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 8 + assert response["status"] == "1" + assert response["message"] == "OK" + end end end