From 24079bb74138fd4d25544a5c258072c3baabffe3 Mon Sep 17 00:00:00 2001 From: Sebastian Abondano Date: Fri, 16 Nov 2018 18:50:17 -0500 Subject: [PATCH] GraphQL support to get internal transactions Why: * For GraphQL API users to be able to get internal transactions. RPC API users could use this instead of the `txlistinternal` RPC API action. Example usage: 1. ``` query ($hash: FullHash!, $first: Int!) { transaction(hash: $hash) { internal_transactions(first: $first) { edges { node { index value block_number transaction_index from_address_hash to_address_hash } } } } } ``` 2. with pagiantion support via [Relay Cursor Connections](https://facebook.github.io/relay/graphql/connections.htm) ``` query ($hash: AddressHash!, $first: Int!, $after: ID!) { transaction(hash: $hash) { internal_transactions(first: $first, after: $after) { page_info { has_next_page has_previous_page } edges { node { index transaction_hash } cursor } } } } ``` * Issue link: n/a This change addresses the need by: * Editing the node interface and node field in `BlockScoutWeb.Schema` to support internal transactions. * Adding `internal_transaction` object type to `BlockScoutWeb.Schema.Types`. * Adding an `internal_transaction` connection field to the `transaction` object type in `BlockScoutWeb.Schema.Types`. * Adding `:call_type` and `:type` enums to `BlockScoutWEb.Schema.Scalars`. Needed by `internal_transaction` GraphQL object type. * Creating `BlockScoutWeb.Resolvers.InternalTransaction` with resolver functions for getting internal transactions. * Adding `get_internal_transactions/1` and `transaction_to_internal_transactions_query/1` to `Explorer.GraphQL`. These are used by the new resolver functions mentioned above. --- .../lib/block_scout_web/etherscan.ex | 2 +- .../resolvers/internal_transaction.ex | 23 + .../block_scout_web/resolvers/transaction.ex | 2 +- .../lib/block_scout_web/schema.ex | 18 +- .../lib/block_scout_web/schema/scalars.ex | 14 + .../lib/block_scout_web/schema/types.ex | 47 ++- .../schema/query/address_test.exs | 22 +- .../schema/query/node_test.exs | 95 +++++ .../schema/query/transaction_test.exs | 394 +++++++++++++++++- apps/explorer/lib/explorer/graphql.ex | 50 ++- apps/explorer/test/explorer/graphql_test.exs | 94 +++++ 11 files changed, 734 insertions(+), 27 deletions(-) create mode 100644 apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex 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 a9890e4293..9ed0c1f64e 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -1012,7 +1012,7 @@ defmodule BlockScoutWeb.Etherscan do @account_txlistinternal_action %{ name: "txlistinternal", description: - "Get internal transactions by transaction or address hash. Up to a maximum of 10,000 internal transactions.", + "Get internal transactions by transaction or address hash. Up to a maximum of 10,000 internal transactions. Also available through a GraphQL 'transaction' query.", required_params: [ %{ key: "txhash", diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex new file mode 100644 index 0000000000..08f3ca45ce --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex @@ -0,0 +1,23 @@ +defmodule BlockScoutWeb.Resolvers.InternalTransaction do + @moduledoc false + + alias Absinthe.Relay.Connection + alias Explorer.Chain.Transaction + alias Explorer.{GraphQL, Repo} + + def get_by(%{transaction_hash: _, index: _} = args) do + GraphQL.get_internal_transaction(args) + end + + def get_by(%Transaction{} = transaction, args, _) do + transaction + |> GraphQL.transaction_to_internal_transactions_query() + |> Connection.from_query(&Repo.all/1, args, options(args)) + end + + defp options(%{before: _}), do: [] + + defp options(%{count: count}), do: [count: count] + + defp options(_), do: [] +end diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex index c13aa2a561..aa54a8e6da 100644 --- a/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex @@ -8,7 +8,7 @@ defmodule BlockScoutWeb.Resolvers.Transaction do def get_by(_, %{hash: hash}, _) do case Chain.hash_to_transaction(hash) do {:ok, transaction} -> {:ok, transaction} - {:error, :not_found} -> {:error, "Transaction hash #{hash} was not found."} + {:error, :not_found} -> {:error, "Transaction not found."} end end diff --git a/apps/block_scout_web/lib/block_scout_web/schema.ex b/apps/block_scout_web/lib/block_scout_web/schema.ex index c310ea844a..7d6d150acd 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema.ex @@ -6,8 +6,16 @@ defmodule BlockScoutWeb.Schema do alias Absinthe.Middleware.Dataloader, as: AbsintheMiddlewareDataloader alias Absinthe.Plugin, as: AbsinthePlugin - alias BlockScoutWeb.Resolvers.{Address, Block, Transaction} + + alias BlockScoutWeb.Resolvers.{ + Address, + Block, + InternalTransaction, + Transaction + } + alias Explorer.Chain + alias Explorer.Chain.InternalTransaction, as: ExplorerChainInternalTransaction alias Explorer.Chain.Transaction, as: ExplorerChainTransaction import_types(BlockScoutWeb.Schema.Types) @@ -17,6 +25,9 @@ defmodule BlockScoutWeb.Schema do %ExplorerChainTransaction{}, _ -> :transaction + %ExplorerChainInternalTransaction{}, _ -> + :internal_transaction + _, _ -> nil end) @@ -29,6 +40,11 @@ defmodule BlockScoutWeb.Schema do {:ok, hash} = Chain.string_to_transaction_hash(transaction_hash_string) Transaction.get_by(%{}, %{hash: hash}, %{}) + %{type: :internal_transaction, id: id}, _ -> + %{"transaction_hash" => transaction_hash_string, "index" => index} = Jason.decode!(id) + {:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string) + InternalTransaction.get_by(%{transaction_hash: transaction_hash, index: index}) + _, _ -> {:error, "Unknown node"} end) diff --git a/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex b/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex index 675992eea4..b7a8939a39 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex @@ -99,4 +99,18 @@ defmodule BlockScoutWeb.Schema.Scalars do value(:ok) value(:error) end + + enum :call_type do + value(:call) + value(:callcode) + value(:delegatecall) + value(:staticcall) + end + + enum :type do + value(:call) + value(:create) + value(:reward) + value(:selfdestruct) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/schema/types.ex index 3492c71fe9..85e843c447 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema/types.ex @@ -6,12 +6,16 @@ defmodule BlockScoutWeb.Schema.Types do import Absinthe.Resolution.Helpers - alias BlockScoutWeb.Resolvers.Transaction + alias BlockScoutWeb.Resolvers.{ + InternalTransaction, + Transaction + } import_types(Absinthe.Type.Custom) import_types(BlockScoutWeb.Schema.Scalars) connection(node_type: :transaction) + connection(node_type: :internal_transaction) @desc """ A stored representation of a Web3 address. @@ -60,6 +64,30 @@ defmodule BlockScoutWeb.Schema.Types do field(:parent_hash, :full_hash) end + @desc """ + Models internal transactions. + """ + node object(:internal_transaction, id_fetcher: &internal_transaction_id_fetcher/2) do + field(:call_type, :call_type) + field(:created_contract_code, :data) + field(:error, :string) + field(:gas, :decimal) + field(:gas_used, :decimal) + field(:index, :integer) + field(:init, :data) + field(:input, :data) + field(:output, :data) + field(:trace_address, :json) + field(:type, :type) + field(:value, :wei) + field(:block_number, :integer) + field(:transaction_index, :integer) + field(:created_contract_address_hash, :address_hash) + field(:from_address_hash, :address_hash) + field(:to_address_hash, :address_hash) + field(:transaction_hash, :full_hash) + end + @desc """ The representation of a verified Smart Contract. @@ -99,6 +127,19 @@ defmodule BlockScoutWeb.Schema.Types do field(:from_address_hash, :address_hash) field(:to_address_hash, :address_hash) field(:created_contract_address_hash, :address_hash) + + connection field(:internal_transactions, node_type: :internal_transaction) do + arg(:count, :integer) + resolve(&InternalTransaction.get_by/3) + + complexity(fn + %{first: first}, child_complexity -> + first * child_complexity + + %{last: last}, child_complexity -> + last * child_complexity + end) + end end @desc """ @@ -113,4 +154,8 @@ defmodule BlockScoutWeb.Schema.Types do end def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash) + + def internal_transaction_id_fetcher(%{transaction_hash: transaction_hash, index: index}, _) do + Jason.encode!(%{transaction_hash: to_string(transaction_hash), index: index}) + end end diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs index 02dff2fe9a..471401f7b6 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs @@ -515,33 +515,13 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do |> List.last() |> Map.get("cursor") - query3 = """ - query ($hash: AddressHash!, $first: Int!, $after: Int!) { - address(hash: $hash) { - transactions(first: $first, after: $after) { - page_info { - has_next_page - has_previous_page - } - edges { - node { - hash - block_number - } - cursor - } - } - } - } - """ - variables3 = %{ "hash" => to_string(address.hash), "first" => 3, "after" => last_cursor_page2 } - conn = get(conn, "/graphql", query: query3, variables: variables3) + conn = get(conn, "/graphql", query: query2, variables: variables3) %{"data" => %{"address" => %{"transactions" => page3}}} = json_response(conn, 200) diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs index 6eac1cf81f..00d21fe85d 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs @@ -31,5 +31,100 @@ defmodule BlockScoutWeb.Schema.Query.NodeTest do } } end + + test "with 'id' for non-existent transaction", %{conn: conn} do + transaction = build(:transaction) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on Transaction { + id + hash + } + } + } + """ + + id = Base.encode64("Transaction:#{transaction.hash}") + + variables = %{"id" => id} + + conn = get(conn, "/graphql", query: query, variables: variables) + + %{"errors" => [error]} = json_response(conn, 200) + + assert error["message"] == "Transaction not found." + end + + test "with valid argument 'id' for an internal transaction", %{conn: conn} do + transaction = insert(:transaction) + + internal_transaction = insert(:internal_transaction, transaction: transaction, index: 0) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on InternalTransaction { + id + transaction_hash + index + } + } + } + """ + + id = + %{transaction_hash: to_string(transaction.hash), index: internal_transaction.index} + |> Jason.encode!() + |> (fn unique_id -> "InternalTransaction:#{unique_id}" end).() + |> Base.encode64() + + variables = %{"id" => id} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "node" => %{ + "id" => id, + "transaction_hash" => to_string(transaction.hash), + "index" => internal_transaction.index + } + } + } + end + + test "with 'id' for non-existent internal transaction", %{conn: conn} do + transaction = build(:transaction) + + internal_transaction = build(:internal_transaction, transaction: transaction, index: 0) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on InternalTransaction { + id + transaction_hash + index + } + } + } + """ + + id = + %{transaction_hash: to_string(transaction.hash), index: internal_transaction.index} + |> Jason.encode!() + |> (fn unique_id -> "InternalTransaction:#{unique_id}" end).() + |> Base.encode64() + + variables = %{"id" => id} + + conn = get(conn, "/graphql", query: query, variables: variables) + + %{"errors" => [error]} = json_response(conn, 200) + + assert error["message"] == "Internal transaction not found." + end end end diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs index cda0bc56db..e76ca54e43 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs @@ -81,7 +81,7 @@ defmodule BlockScoutWeb.Schema.Query.TransactionTest do conn = get(conn, "/graphql", query: query, variables: variables) assert %{"errors" => [error]} = json_response(conn, 200) - assert error["message"] =~ ~s(Transaction hash #{transaction.hash} was not found) + assert error["message"] == "Transaction not found." end test "errors if argument 'hash' is missing", %{conn: conn} do @@ -116,4 +116,396 @@ defmodule BlockScoutWeb.Schema.Query.TransactionTest do assert error["message"] =~ ~s(Argument "hash" has invalid value) end end + + describe "transaction internal_transactions field" do + test "returns all expected internal_transaction fields", %{conn: conn} do + address = insert(:address) + contract_address = insert(:contract_address) + + block = insert(:block) + + transaction = + :transaction + |> insert(from_address: address) + |> with_contract_creation(contract_address) + |> with_block(block) + + internal_transaction_attributes = %{ + transaction: transaction, + index: 0, + from_address: address, + call_type: :call + } + + internal_transaction = + :internal_transaction_create + |> insert(internal_transaction_attributes) + |> with_contract_creation(contract_address) + + query = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + call_type + created_contract_code + error + gas + gas_used + index + init + input + output + trace_address + type + value + block_number + transaction_index + created_contract_address_hash + from_address_hash + to_address_hash + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "transaction" => %{ + "internal_transactions" => %{ + "edges" => [ + %{ + "node" => %{ + "call_type" => internal_transaction.call_type |> to_string() |> String.upcase(), + "created_contract_code" => to_string(internal_transaction.created_contract_code), + "error" => internal_transaction.error, + "gas" => to_string(internal_transaction.gas), + "gas_used" => to_string(internal_transaction.gas_used), + "index" => internal_transaction.index, + "init" => to_string(internal_transaction.init), + "input" => nil, + "output" => nil, + "trace_address" => Jason.encode!(internal_transaction.trace_address), + "type" => internal_transaction.type |> to_string() |> String.upcase(), + "value" => to_string(internal_transaction.value.value), + "block_number" => internal_transaction.block_number, + "transaction_index" => internal_transaction.transaction_index, + "created_contract_address_hash" => + to_string(internal_transaction.created_contract_address_hash), + "from_address_hash" => to_string(internal_transaction.from_address_hash), + "to_address_hash" => nil, + "transaction_hash" => to_string(internal_transaction.transaction_hash) + } + } + ] + } + } + } + } + end + + test "with transaction with zero internal transactions", %{conn: conn} do + address = insert(:address) + + block = insert(:block) + + transaction = + :transaction + |> insert(from_address: address) + |> with_block(block) + + query = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "transaction" => %{ + "internal_transactions" => %{ + "edges" => [] + } + } + } + } + end + + test "internal transactions are ordered by ascending index", %{conn: conn} do + transaction = insert(:transaction) + + insert(:internal_transaction, transaction: transaction, index: 2) + insert(:internal_transaction, transaction: transaction, index: 0) + insert(:internal_transaction, transaction: transaction, index: 1) + + query = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "first" => 3 + } + + response = + conn + |> get("/graphql", query: query, variables: variables) + |> json_response(200) + + internal_transactions = get_in(response, ["data", "transaction", "internal_transactions", "edges"]) + + index_order = Enum.map(internal_transactions, & &1["node"]["index"]) + + assert index_order == Enum.sort(index_order) + end + + test "complexity correlates to first or last argument", %{conn: conn} do + transaction = insert(:transaction) + + query1 = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables1 = %{ + "hash" => to_string(transaction.hash), + "first" => 55 + } + + response1 = + conn + |> get("/graphql", query: query1, variables: variables1) + |> json_response(200) + + assert %{"errors" => [error1, error2, error3]} = response1 + assert error1["message"] =~ ~s(Field internal_transactions is too complex) + assert error2["message"] =~ ~s(Field transaction is too complex) + assert error3["message"] =~ ~s(Operation is too complex) + + query2 = """ + query ($hash: FullHash!, $last: Int!, $count: Int!) { + transaction(hash: $hash) { + internal_transactions(last: $last, count: $count) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables2 = %{ + "hash" => to_string(transaction.hash), + "last" => 55, + "count" => 100 + } + + response2 = + conn + |> get("/graphql", query: query2, variables: variables2) + |> json_response(200) + + assert %{"errors" => [error1, error2, error3]} = response2 + assert error1["message"] =~ ~s(Field internal_transactions is too complex) + assert error2["message"] =~ ~s(Field transaction is too complex) + assert error3["message"] =~ ~s(Operation is too complex) + end + + test "with 'last' and 'count' arguments", %{conn: conn} do + # "`last: N` must always be acompanied by either a `before:` argument to + # the query, or an explicit `count:` option to the `from_query` call. + # Otherwise it is impossible to derive the required offset." + # https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Connection.html#from_query/4 + # + # This test ensures support for a 'count' argument. + + transaction = insert(:transaction) + + insert(:internal_transaction, transaction: transaction, index: 2) + insert(:internal_transaction, transaction: transaction, index: 0) + insert(:internal_transaction, transaction: transaction, index: 1) + + query = """ + query ($hash: FullHash!, $last: Int!, $count: Int!) { + transaction(hash: $hash) { + internal_transactions(last: $last, count: $count) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "last" => 1, + "count" => 3 + } + + [internal_transaction] = + conn + |> get("/graphql", query: query, variables: variables) + |> json_response(200) + |> get_in(["data", "transaction", "internal_transactions", "edges"]) + + assert internal_transaction["node"]["index"] == 2 + end + + test "pagination support with 'first' and 'after' arguments", %{conn: conn} do + transaction = insert(:transaction) + + for index <- 0..5 do + insert(:internal_transaction_create, transaction: transaction, index: index) + end + + query1 = """ + query ($hash: AddressHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + index + transaction_hash + } + cursor + } + } + } + } + """ + + variables1 = %{ + "hash" => to_string(transaction.hash), + "first" => 2 + } + + conn = get(conn, "/graphql", query: query1, variables: variables1) + + %{"data" => %{"transaction" => %{"internal_transactions" => page1}}} = json_response(conn, 200) + + assert page1["page_info"] == %{"has_next_page" => true, "has_previous_page" => false} + assert Enum.all?(page1["edges"], &(&1["node"]["index"] in 0..1)) + + last_cursor_page1 = + page1 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + query2 = """ + query ($hash: AddressHash!, $first: Int!, $after: ID!) { + transaction(hash: $hash) { + internal_transactions(first: $first, after: $after) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + index + transaction_hash + } + cursor + } + } + } + } + """ + + variables2 = %{ + "hash" => to_string(transaction.hash), + "first" => 2, + "after" => last_cursor_page1 + } + + page2 = + conn + |> get("/graphql", query: query2, variables: variables2) + |> json_response(200) + |> get_in(["data", "transaction", "internal_transactions"]) + + assert page2["page_info"] == %{"has_next_page" => true, "has_previous_page" => true} + assert Enum.all?(page2["edges"], &(&1["node"]["index"] in 2..3)) + + last_cursor_page2 = + page2 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + variables3 = %{ + "hash" => to_string(transaction.hash), + "first" => 2, + "after" => last_cursor_page2 + } + + page3 = + conn + |> get("/graphql", query: query2, variables: variables3) + |> json_response(200) + |> get_in(["data", "transaction", "internal_transactions"]) + + assert page3["page_info"] == %{"has_next_page" => false, "has_previous_page" => true} + assert Enum.all?(page3["edges"], &(&1["node"]["index"] in 4..5)) + end + end end diff --git a/apps/explorer/lib/explorer/graphql.ex b/apps/explorer/lib/explorer/graphql.ex index 50d3161861..b3265a4150 100644 --- a/apps/explorer/lib/explorer/graphql.ex +++ b/apps/explorer/lib/explorer/graphql.ex @@ -5,13 +5,28 @@ defmodule Explorer.GraphQL do import Ecto.Query, only: [ + from: 2, order_by: 3, or_where: 3, where: 3 ] - alias Explorer.Chain.{Address, Hash, Transaction} + alias Explorer.Chain.{ + Address, + Hash, + InternalTransaction, + Transaction + } + + alias Explorer.{Chain, Repo} + + @doc """ + Returns a query to fetch transactions with a matching `to_address_hash`, + `from_address_hash`, or `created_contract_address_hash` field for a given address. + Orders transactions by descending block number and index. + """ + @spec address_to_transactions_query(Address.t()) :: Ecto.Query.t() def address_to_transactions_query(%Address{hash: %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash}) do Transaction |> order_by([transaction], desc: transaction.block_number, desc: transaction.index) @@ -19,4 +34,37 @@ defmodule Explorer.GraphQL do |> or_where([transaction], transaction.from_address_hash == ^address_hash) |> or_where([transaction], transaction.created_contract_address_hash == ^address_hash) end + + @doc """ + Returns an internal transaction for a given transaction hash and index. + """ + @spec get_internal_transaction(map()) :: {:ok, InternalTransaction.t()} | {:error, String.t()} + def get_internal_transaction(%{transaction_hash: _, index: _} = clauses) do + if internal_transaction = Repo.get_by(InternalTransaction, clauses) do + {:ok, internal_transaction} + else + {:error, "Internal transaction not found."} + end + end + + @doc """ + Returns a query to fetch internal transactions for a given transaction. + + Orders internal transactions by ascending index. + """ + @spec transaction_to_internal_transactions_query(Transaction.t()) :: Ecto.Query.t() + def transaction_to_internal_transactions_query(%Transaction{ + hash: %Hash{byte_count: unquote(Hash.Full.byte_count())} = hash + }) do + query = + from( + it in InternalTransaction, + inner_join: t in assoc(it, :transaction), + order_by: [asc: it.index], + where: it.transaction_hash == ^hash, + select: it + ) + + Chain.where_transaction_has_multiple_internal_transactions(query) + end end diff --git a/apps/explorer/test/explorer/graphql_test.exs b/apps/explorer/test/explorer/graphql_test.exs index 91cc1c6c00..f98a030bb2 100644 --- a/apps/explorer/test/explorer/graphql_test.exs +++ b/apps/explorer/test/explorer/graphql_test.exs @@ -87,4 +87,98 @@ defmodule Explorer.GraphQLTest do assert block_number_and_index_order == Enum.sort(block_number_and_index_order, &(&1 >= &2)) end end + + describe "get_internal_transaction/1" do + test "returns existing internal transaction" do + transaction = insert(:transaction) + + internal_transaction = insert(:internal_transaction, transaction: transaction, index: 0) + + clauses = %{transaction_hash: transaction.hash, index: internal_transaction.index} + + {:ok, found_internal_transaction} = GraphQL.get_internal_transaction(clauses) + + assert found_internal_transaction.transaction_hash == transaction.hash + assert found_internal_transaction.index == internal_transaction.index + end + + test "returns error tuple for non-existent internal transaction" do + transaction = build(:transaction) + + internal_transaction = build(:internal_transaction, transaction: transaction, index: 0) + + clauses = %{transaction_hash: transaction.hash, index: internal_transaction.index} + + assert GraphQL.get_internal_transaction(clauses) == {:error, "Internal transaction not found."} + end + end + + describe "transcation_to_internal_transactions_query/1" do + test "with transaction with one internal transaction" do + transaction1 = insert(:transaction) + transaction2 = insert(:transaction) + + internal_transaction = insert(:internal_transaction_create, transaction: transaction1, index: 0) + insert(:internal_transaction_create, transaction: transaction2, index: 0) + + [found_internal_transaction] = + transaction1 + |> GraphQL.transaction_to_internal_transactions_query() + |> Repo.all() + + assert found_internal_transaction.transaction_hash == transaction1.hash + assert found_internal_transaction.index == internal_transaction.index + end + + test "with transaction with multiple internal transactions" do + transaction1 = insert(:transaction) + transaction2 = insert(:transaction) + + for index <- 0..2 do + insert(:internal_transaction_create, transaction: transaction1, index: index) + end + + insert(:internal_transaction_create, transaction: transaction2, index: 0) + + found_internal_transactions = + transaction1 + |> GraphQL.transaction_to_internal_transactions_query() + |> Repo.all() + + assert length(found_internal_transactions) == 3 + + for found_internal_transaction <- found_internal_transactions do + assert found_internal_transaction.transaction_hash == transaction1.hash + end + end + + test "orders internal transactions by ascending index" do + transaction = insert(:transaction) + + insert(:internal_transaction_create, transaction: transaction, index: 2) + insert(:internal_transaction_create, transaction: transaction, index: 0) + insert(:internal_transaction_create, transaction: transaction, index: 1) + + found_internal_transactions = + transaction + |> GraphQL.transaction_to_internal_transactions_query() + |> Repo.all() + + index_order = Enum.map(found_internal_transactions, & &1.index) + + assert index_order == Enum.sort(index_order) + end + + # Note that `transaction_to_internal_transactions_query/1` relies on + # `Explorer.Chain.where_transaction_has_multiple_transactions/1` to ensure the + # following behavior: + # + # * exclude internal transactions of type call with no siblings in the + # transaction + # + # * include internal transactions of type create, reward, or suicide + # even when they are alone in the parent transaction + # + # These two requirements are tested in `Explorer.ChainTest`. + end end