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 9ed0c1f64e..e8b14869cc 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -1087,7 +1087,8 @@ defmodule BlockScoutWeb.Etherscan do @account_tokentx_action %{ name: "tokentx", - description: "Get token transfer events by address. Up to a maximum of 10,000 token transfer events.", + description: + "Get token transfer events by address. Up to a maximum of 10,000 token transfer events. Also available through a GraphQL 'token_transfers' query.", required_params: [ %{ key: "address", diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/token_transfer.ex new file mode 100644 index 0000000000..38b8b3f4ac --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/token_transfer.ex @@ -0,0 +1,24 @@ +defmodule BlockScoutWeb.Resolvers.TokenTransfer do + @moduledoc false + + alias Absinthe.Relay.Connection + alias Explorer.{GraphQL, Repo} + + def get_by(%{transaction_hash: _, log_index: _} = args) do + GraphQL.get_token_transfer(args) + end + + def get_by(_, %{token_contract_address_hash: token_contract_address_hash} = args, _) do + connection_args = Map.take(args, [:after, :before, :first, :last]) + + token_contract_address_hash + |> GraphQL.list_token_transfers_query() + |> Connection.from_query(&Repo.all/1, connection_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/schema.ex b/apps/block_scout_web/lib/block_scout_web/schema.ex index 7d6d150acd..42d644c418 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema.ex @@ -11,23 +11,28 @@ defmodule BlockScoutWeb.Schema do Address, Block, InternalTransaction, + TokenTransfer, Transaction } alias Explorer.Chain alias Explorer.Chain.InternalTransaction, as: ExplorerChainInternalTransaction + alias Explorer.Chain.TokenTransfer, as: ExplorerChainTokenTransfer alias Explorer.Chain.Transaction, as: ExplorerChainTransaction import_types(BlockScoutWeb.Schema.Types) node interface do resolve_type(fn - %ExplorerChainTransaction{}, _ -> - :transaction - %ExplorerChainInternalTransaction{}, _ -> :internal_transaction + %ExplorerChainTokenTransfer{}, _ -> + :token_transfer + + %ExplorerChainTransaction{}, _ -> + :transaction + _, _ -> nil end) @@ -36,15 +41,20 @@ defmodule BlockScoutWeb.Schema do query do node field do resolve(fn - %{type: :transaction, id: transaction_hash_string}, _ -> - {: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}) + %{type: :token_transfer, id: id}, _ -> + %{"transaction_hash" => transaction_hash_string, "log_index" => log_index} = Jason.decode!(id) + {:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string) + TokenTransfer.get_by(%{transaction_hash: transaction_hash, log_index: log_index}) + + %{type: :transaction, id: transaction_hash_string}, _ -> + {:ok, hash} = Chain.string_to_transaction_hash(transaction_hash_string) + Transaction.get_by(%{}, %{hash: hash}, %{}) + _, _ -> {:error, "Unknown node"} end) @@ -69,6 +79,22 @@ defmodule BlockScoutWeb.Schema do resolve(&Block.get_by/3) end + @desc "Gets token transfers by token contract address hash." + connection field(:token_transfers, node_type: :token_transfer) do + arg(:token_contract_address_hash, non_null(:address_hash)) + arg(:count, :integer) + + resolve(&TokenTransfer.get_by/3) + + complexity(fn + %{first: first}, child_complexity -> + first * child_complexity + + %{last: last}, child_complexity -> + last * child_complexity + end) + end + @desc "Gets a transaction by hash." field :transaction, :transaction do arg(:hash, non_null(:full_hash)) 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 85e843c447..029b322f9c 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 @@ -16,6 +16,7 @@ defmodule BlockScoutWeb.Schema.Types do connection(node_type: :transaction) connection(node_type: :internal_transaction) + connection(node_type: :token_transfer) @desc """ A stored representation of a Web3 address. @@ -105,6 +106,20 @@ defmodule BlockScoutWeb.Schema.Types do field(:address_hash, :address_hash) end + @desc """ + Represents a token transfer between addresses. + """ + node object(:token_transfer, id_fetcher: &token_transfer_id_fetcher/2) do + field(:amount, :decimal) + field(:block_number, :integer) + field(:log_index, :integer) + field(:token_id, :decimal) + field(:from_address_hash, :address_hash) + field(:to_address_hash, :address_hash) + field(:token_contract_address_hash, :address_hash) + field(:transaction_hash, :full_hash) + end + @desc """ Models a Web3 transaction. """ @@ -142,15 +157,8 @@ defmodule BlockScoutWeb.Schema.Types do end end - @desc """ - Represents a token transfer between addresses. - """ - object :token_transfer do - field(:amount, :decimal) - field(:from_address_hash, :address_hash) - field(:to_address_hash, :address_hash) - field(:token_contract_address_hash, :address_hash) - field(:transaction_hash, :full_hash) + def token_transfer_id_fetcher(%{transaction_hash: transaction_hash, log_index: log_index}, _) do + Jason.encode!(%{transaction_hash: to_string(transaction_hash), log_index: log_index}) end def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash) 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 00d21fe85d..a8330f085a 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 @@ -126,5 +126,72 @@ defmodule BlockScoutWeb.Schema.Query.NodeTest do assert error["message"] == "Internal transaction not found." end + + test "with valid argument 'id' for a token_transfer", %{conn: conn} do + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on TokenTransfer { + id + transaction_hash + log_index + } + } + } + """ + + id = + %{transaction_hash: to_string(token_transfer.transaction_hash), log_index: token_transfer.log_index} + |> Jason.encode!() + |> (fn unique_id -> "TokenTransfer:#{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(token_transfer.transaction_hash), + "log_index" => token_transfer.log_index + } + } + } + end + + test "with id for non-existent token transfer", %{conn: conn} do + transaction = build(:transaction) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on TokenTransfer { + id + transaction_hash + log_index + } + } + } + """ + + id = + %{transaction_hash: to_string(transaction.hash), log_index: 0} + |> Jason.encode!() + |> (fn unique_id -> "TokenTransfer:#{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"] == "Token transfer not found." + end end end diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/token_transfers_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/token_transfers_test.exs new file mode 100644 index 0000000000..34c1cdfb1f --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/schema/query/token_transfers_test.exs @@ -0,0 +1,326 @@ +defmodule BlockScoutWeb.Schema.Query.TokenTransfersTest do + use BlockScoutWeb.ConnCase + + describe "token_transfers field" do + test "with valid argument, returns all expected fields", %{conn: conn} do + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction) + address_hash = to_string(token_transfer.token_contract_address_hash) + + query = """ + query ($token_contract_address_hash: AddressHash!, $first: Int!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, first: $first) { + edges { + node { + amount + block_number + log_index + token_id + from_address_hash + to_address_hash + token_contract_address_hash + transaction_hash + } + } + } + } + """ + + variables = %{ + "token_contract_address_hash" => address_hash, + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "token_transfers" => %{ + "edges" => [ + %{ + "node" => %{ + "amount" => to_string(token_transfer.amount), + "block_number" => token_transfer.block_number, + "log_index" => token_transfer.log_index, + "token_id" => token_transfer.token_id, + "from_address_hash" => to_string(token_transfer.from_address_hash), + "to_address_hash" => to_string(token_transfer.to_address_hash), + "token_contract_address_hash" => to_string(token_transfer.token_contract_address_hash), + "transaction_hash" => to_string(token_transfer.transaction_hash) + } + } + ] + } + } + } + end + + test "with token contract address with zero token transfers", %{conn: conn} do + address = insert(:contract_address) + + query = """ + query ($token_contract_address_hash: AddressHash!, $first: Int!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, first: $first) { + edges { + node { + amount + block_number + log_index + token_id + from_address_hash + to_address_hash + token_contract_address_hash + transaction_hash + } + } + } + } + """ + + variables = %{ + "token_contract_address_hash" => to_string(address.hash), + "first" => 10 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "token_transfers" => %{ + "edges" => [] + } + } + } + end + + test "complexity correlates to first or last argument", %{conn: conn} do + address = insert(:contract_address) + + query1 = """ + query ($token_contract_address_hash: AddressHash!, $first: Int!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, first: $first) { + edges { + node { + amount + from_address_hash + to_address_hash + } + } + } + } + """ + + variables1 = %{ + "token_contract_address_hash" => to_string(address.hash), + "first" => 55 + } + + response1 = + conn + |> get("/graphql", query: query1, variables: variables1) + |> json_response(200) + + %{"errors" => [response1_error1, response1_error2]} = response1 + + assert response1_error1["message"] =~ ~s(Field token_transfers is too complex) + assert response1_error2["message"] =~ ~s(Operation is too complex) + + query2 = """ + query ($token_contract_address_hash: AddressHash!, $last: Int!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, last: $last) { + edges { + node { + amount + from_address_hash + to_address_hash + } + } + } + } + """ + + variables2 = %{ + "token_contract_address_hash" => to_string(address.hash), + "last" => 55 + } + + response2 = + conn + |> get("/graphql", query: query2, variables: variables2) + |> json_response(200) + + %{"errors" => [response2_error1, response2_error2]} = response2 + assert response2_error1["message"] =~ ~s(Field token_transfers is too complex) + assert response2_error2["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. + + address = insert(:contract_address) + + blocks = insert_list(2, :block) + + [transaction1, transaction2] = + for block <- blocks do + :transaction + |> insert() + |> with_block(block) + end + + token_transfer_attrs1 = %{ + block_number: transaction1.block_number, + transaction: transaction1, + token_contract_address: address + } + + token_transfer_attrs2 = %{ + block_number: transaction2.block_number, + transaction: transaction2, + token_contract_address: address + } + + insert(:token_transfer, token_transfer_attrs1) + insert(:token_transfer, token_transfer_attrs2) + + query = """ + query ($token_contract_address_hash: AddressHash!, $last: Int!, $count: Int) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, last: $last, count: $count) { + edges { + node { + transaction_hash + } + } + } + } + """ + + variables = %{ + "token_contract_address_hash" => to_string(address.hash), + "last" => 1, + "count" => 2 + } + + [token_transfer] = + conn + |> get("/graphql", query: query, variables: variables) + |> json_response(200) + |> get_in(["data", "token_transfers", "edges"]) + + assert token_transfer["node"]["transaction_hash"] == to_string(transaction1.hash) + end + + test "pagination support with 'first' and 'after' arguments", %{conn: conn} do + address = insert(:contract_address) + + blocks = insert_list(3, :block) + + [transaction1, transaction2, transaction3] = + transactions = + for block <- blocks do + :transaction + |> insert() + |> with_block(block) + end + + for transaction <- transactions do + token_transfer_attrs = %{ + block_number: transaction.block_number, + transaction: transaction, + token_contract_address: address + } + + insert(:token_transfer, token_transfer_attrs) + end + + query1 = """ + query ($token_contract_address_hash: AddressHash!, $first: Int!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, first: $first) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + transaction_hash + } + cursor + } + } + } + """ + + variables1 = %{ + "token_contract_address_hash" => to_string(address.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query1, variables: variables1) + + %{"data" => %{"token_transfers" => page1}} = json_response(conn, 200) + + assert page1["page_info"] == %{"has_next_page" => true, "has_previous_page" => false} + assert Enum.all?(page1["edges"], &(&1["node"]["transaction_hash"] == to_string(transaction3.hash))) + + last_cursor_page1 = + page1 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + query2 = """ + query ($token_contract_address_hash: AddressHash!, $first: Int!, $after: ID!) { + token_transfers(token_contract_address_hash: $token_contract_address_hash, first: $first, after: $after) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + transaction_hash + } + cursor + } + } + } + """ + + variables2 = %{ + "token_contract_address_hash" => to_string(address.hash), + "first" => 1, + "after" => last_cursor_page1 + } + + conn = get(conn, "/graphql", query: query2, variables: variables2) + + %{"data" => %{"token_transfers" => page2}} = json_response(conn, 200) + + assert page2["page_info"] == %{"has_next_page" => true, "has_previous_page" => true} + assert Enum.all?(page2["edges"], &(&1["node"]["transaction_hash"] == to_string(transaction2.hash))) + + last_cursor_page2 = + page2 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + variables3 = %{ + "token_contract_address_hash" => to_string(address.hash), + "first" => 1, + "after" => last_cursor_page2 + } + + conn = get(conn, "/graphql", query: query2, variables: variables3) + + %{"data" => %{"token_transfers" => page3}} = json_response(conn, 200) + + assert page3["page_info"] == %{"has_next_page" => false, "has_previous_page" => true} + assert Enum.all?(page3["edges"], &(&1["node"]["transaction_hash"] == to_string(transaction1.hash))) + end + end +end diff --git a/apps/explorer/lib/explorer/graphql.ex b/apps/explorer/lib/explorer/graphql.ex index b3265a4150..2ab931f743 100644 --- a/apps/explorer/lib/explorer/graphql.ex +++ b/apps/explorer/lib/explorer/graphql.ex @@ -15,6 +15,7 @@ defmodule Explorer.GraphQL do Address, Hash, InternalTransaction, + TokenTransfer, Transaction } @@ -67,4 +68,32 @@ defmodule Explorer.GraphQL do Chain.where_transaction_has_multiple_internal_transactions(query) end + + @doc """ + Returns a token transfer for a given transaction hash and log index. + """ + @spec get_token_transfer(map()) :: {:ok, TokenTransfer.t()} | {:error, String.t()} + def get_token_transfer(%{transaction_hash: _, log_index: _} = clauses) do + if token_transfer = Repo.get_by(TokenTransfer, clauses) do + {:ok, token_transfer} + else + {:error, "Token transfer not found."} + end + end + + @doc """ + Returns a query to fetch token transfers for a token contract address hash. + + Orders token transfers by descending block number, descending transaction index, and ascending log index. + """ + @spec list_token_transfers_query(Hash.t()) :: Ecto.Query.t() + def list_token_transfers_query(%Hash{byte_count: unquote(Hash.Address.byte_count())} = token_contract_address_hash) do + from( + tt in TokenTransfer, + inner_join: t in assoc(tt, :transaction), + where: tt.token_contract_address_hash == ^token_contract_address_hash, + order_by: [desc: tt.block_number, desc: t.index, asc: tt.log_index], + select: tt + ) + end end diff --git a/apps/explorer/test/explorer/graphql_test.exs b/apps/explorer/test/explorer/graphql_test.exs index f98a030bb2..ef0dce974d 100644 --- a/apps/explorer/test/explorer/graphql_test.exs +++ b/apps/explorer/test/explorer/graphql_test.exs @@ -181,4 +181,129 @@ defmodule Explorer.GraphQLTest do # # These two requirements are tested in `Explorer.ChainTest`. end + + describe "get_token_transfer/1" do + test "returns existing token transfer" do + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction) + + clauses = %{transaction_hash: token_transfer.transaction_hash, log_index: token_transfer.log_index} + + {:ok, found_token_transfer} = GraphQL.get_token_transfer(clauses) + + assert found_token_transfer.transaction_hash == token_transfer.transaction_hash + assert found_token_transfer.log_index == token_transfer.log_index + end + + test " returns error tuple for non-existing token transfer" do + transaction = insert(:transaction) + token_transfer = build(:token_transfer, transaction: transaction) + + clauses = %{transaction_hash: transaction.hash, log_index: token_transfer.log_index} + + assert GraphQL.get_token_transfer(clauses) == {:error, "Token transfer not found."} + end + end + + describe "list_token_transfers_query/1" do + test "with token contract address hash with zero token transfers" do + result = + :address + |> insert() + |> Map.get(:hash) + |> GraphQL.list_token_transfers_query() + |> Repo.all() + + assert result == [] + end + + test "returns all expected token transfer fields" do + transaction = insert(:transaction) + token_transfer = insert(:token_transfer, transaction: transaction) + + [found_token_transfer] = + token_transfer.token_contract_address_hash + |> GraphQL.list_token_transfers_query() + |> Repo.all() + + expected_fields = ~w( + amount + block_number + log_index + token_id + from_address_hash + to_address_hash + token_contract_address_hash + transaction_hash + )a + + for expected_field <- expected_fields do + assert Map.get(found_token_transfer, expected_field) == Map.get(token_transfer, expected_field) + end + end + + test "orders token transfers by descending block number, descending transaction index, and ascending log index" do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + + transactions_block2 = + 2 + |> insert_list(:transaction) + |> with_block(second_block) + + transactions_block3 = + 2 + |> insert_list(:transaction) + |> with_block(third_block) + + transactions_block1 = + 2 + |> insert_list(:transaction) + |> with_block(first_block) + + all_transactions = Enum.concat([transactions_block2, transactions_block3, transactions_block1]) + + token_address = insert(:contract_address) + insert(:token, contract_address: token_address) + + for transaction <- all_transactions do + token_transfer_attrs1 = %{ + block_number: transaction.block_number, + log_index: 0, + transaction: transaction, + token_contract_address: token_address + } + + token_transfer_attrs2 = %{ + block_number: transaction.block_number, + log_index: 1, + transaction: transaction, + token_contract_address: token_address + } + + insert(:token_transfer, token_transfer_attrs1) + insert(:token_transfer, token_transfer_attrs2) + end + + found_token_transfers = + token_address.hash + |> GraphQL.list_token_transfers_query() + |> Repo.all() + |> Repo.preload(:transaction) + + block_number_order = Enum.map(found_token_transfers, & &1.block_number) + transaction_index_order = Enum.map(found_token_transfers, &{&1.block_number, &1.transaction.index}) + + assert block_number_order == Enum.sort(block_number_order, &(&1 >= &2)) + assert transaction_index_order == Enum.sort(transaction_index_order, &(&1 >= &2)) + + tt_by_block_transaction = Enum.group_by(found_token_transfers, &{&1.block_number, &1.transaction.index}) + + for {_, token_transfers} <- tt_by_block_transaction do + log_index_order = Enum.map(token_transfers, & &1.log_index) + assert log_index_order == Enum.sort(log_index_order) + end + end + end end