From 195f7d39eed01f6214c172217efa0e44509c01d5 Mon Sep 17 00:00:00 2001 From: Sebastian Abondano Date: Wed, 14 Nov 2018 12:35:16 -0500 Subject: [PATCH] GraphQL support to get transactions by address Why: * We'd like to add support for GraphQL queries to get transactions by address hash. RPC API users could use this instead of the `txlist` RPC API action. Example usage: 1. ``` query($hash: AddressHash!, $first: Int!) { address(hash: $hash) { transactions(first: $first) { edges { node { hash blockNumber } cursor } } } ``` 2. with pagination support via [Relay Cursor Connections](https://facebook.github.io/relay/graphql/connections.htm) ``` query($hash: AddressHash!, $first: Int!, $after: String!) { address(hash: $hash) { transactions(first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage } edges { node { hash blockNumber } cursor } } } } ``` * Issue link: n/a This change addresses the need by: * Renaming `BlockScoutWeb.Schema.Query.AddressTest` to `...AddressesTest` for the name to match the query field it is testing. * Adding `:absinthe_relay` dependency to `BlockScoutWeb` app for Absinthe support for the [Relay framework](https://facebook.github.io/relay/graphql/connections.htm). * Editing `BlockScoutWeb.Schema` to use `Absinthe.Relay.Schema`, define a node interface, and define a node field. This was necessary to support relay connections. * Adding an `address` field to `BlockScoutWeb.Schema`, to get a single address by hash. * Adding a `TransactionConnection` field to the `address` object type in `BlockScoutWeb.Schema.Types` * Configuring the `transaction` object type as a node, in `BlockScoutWeb.Schema.Types`. * Adding a resolver function in `BlockScoutWeb.Resolvers.Address`, to get a single address. * Adding a resolver function in `BlockScoutWeb.Resolvers.Transaction`, to get transactions for an address. * Creating `Explorer.GraphQL` for GraphQL API specific Ecto queries. The only function in this module now is `address_to_transactions_query/1`. This function is necessary for `Absinthe.Relay.Connection.from_query/4` to load transactions in the new transaction resolver mentioned above. * Editing RPC API docs to include reference to the GraphQL "transactions" field. --- .../lib/block_scout_web/etherscan.ex | 3 +- .../lib/block_scout_web/resolvers/address.ex | 7 + .../block_scout_web/resolvers/transaction.ex | 16 +- .../lib/block_scout_web/schema.ex | 29 ++ .../lib/block_scout_web/schema/types.ex | 22 +- apps/block_scout_web/mix.exs | 2 + .../schema/query/address_test.exs | 493 +++++++++++++++--- .../schema/query/addresses_test.exs | 185 +++++++ .../schema/query/node_test.exs | 35 ++ apps/explorer/lib/explorer/graphql.ex | 22 + apps/explorer/test/explorer/graphql_test.exs | 90 ++++ mix.lock | 1 + 12 files changed, 839 insertions(+), 66 deletions(-) create mode 100644 apps/block_scout_web/test/block_scout_web/schema/query/addresses_test.exs create mode 100644 apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs create mode 100644 apps/explorer/lib/explorer/graphql.ex create mode 100644 apps/explorer/test/explorer/graphql_test.exs 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 7075210ed0..a9890e4293 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -925,7 +925,8 @@ defmodule BlockScoutWeb.Etherscan do @account_txlist_action %{ name: "txlist", - description: "Get transactions by address. Up to a maximum of 10,000 transactions.", + description: + "Get transactions by address. Up to a maximum of 10,000 transactions. Also available through a GraphQL 'address' query.", required_params: [ %{ key: "address", diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex index f731f1c7cd..dda5aaf792 100644 --- a/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex @@ -9,4 +9,11 @@ defmodule BlockScoutWeb.Resolvers.Address do result -> {:ok, result} end end + + def get_by(_, %{hash: hash}, _) do + case Chain.hash_to_address(hash) do + {:error, :not_found} -> {:error, "Address not found."} + {:ok, _} = result -> result + end + end 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 f3e4a77b21..c13aa2a561 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 @@ -1,7 +1,9 @@ defmodule BlockScoutWeb.Resolvers.Transaction do @moduledoc false - alias Explorer.Chain + alias Absinthe.Relay.Connection + alias Explorer.{Chain, GraphQL, Repo} + alias Explorer.Chain.Address def get_by(_, %{hash: hash}, _) do case Chain.hash_to_transaction(hash) do @@ -9,4 +11,16 @@ defmodule BlockScoutWeb.Resolvers.Transaction do {:error, :not_found} -> {:error, "Transaction hash #{hash} was not found."} end end + + def get_by(%Address{} = address, args, _) do + address + |> GraphQL.address_to_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/schema.ex b/apps/block_scout_web/lib/block_scout_web/schema.ex index ba6ef69d96..c310ea844a 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema.ex @@ -2,15 +2,44 @@ defmodule BlockScoutWeb.Schema do @moduledoc false use Absinthe.Schema + use Absinthe.Relay.Schema, :modern alias Absinthe.Middleware.Dataloader, as: AbsintheMiddlewareDataloader alias Absinthe.Plugin, as: AbsinthePlugin alias BlockScoutWeb.Resolvers.{Address, Block, Transaction} alias Explorer.Chain + alias Explorer.Chain.Transaction, as: ExplorerChainTransaction import_types(BlockScoutWeb.Schema.Types) + node interface do + resolve_type(fn + %ExplorerChainTransaction{}, _ -> + :transaction + + _, _ -> + nil + end) + end + 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}, %{}) + + _, _ -> + {:error, "Unknown node"} + end) + end + + @desc "Gets an address by hash." + field :address, :address do + arg(:hash, non_null(:address_hash)) + resolve(&Address.get_by/3) + end + @desc "Gets addresses by address hash." field :addresses, list_of(:address) do arg(:hashes, non_null(list_of(non_null(:address_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 e67580da0d..3492c71fe9 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 @@ -2,12 +2,17 @@ defmodule BlockScoutWeb.Schema.Types do @moduledoc false use Absinthe.Schema.Notation + use Absinthe.Relay.Schema.Notation, :modern import Absinthe.Resolution.Helpers + alias BlockScoutWeb.Resolvers.Transaction + import_types(Absinthe.Type.Custom) import_types(BlockScoutWeb.Schema.Scalars) + connection(node_type: :transaction) + @desc """ A stored representation of a Web3 address. """ @@ -20,6 +25,19 @@ defmodule BlockScoutWeb.Schema.Types do field :smart_contract, :smart_contract do resolve(dataloader(:db, :smart_contract)) end + + connection field(:transactions, node_type: :transaction) do + arg(:count, :integer) + resolve(&Transaction.get_by/3) + + complexity(fn + %{first: first}, child_complexity -> + first * child_complexity + + %{last: last}, child_complexity -> + last * child_complexity + end) + end end @desc """ @@ -62,7 +80,7 @@ defmodule BlockScoutWeb.Schema.Types do @desc """ Models a Web3 transaction. """ - object :transaction do + node object(:transaction, id_fetcher: &transaction_id_fetcher/2) do field(:hash, :full_hash) field(:block_number, :integer) field(:cumulative_gas_used, :decimal) @@ -93,4 +111,6 @@ defmodule BlockScoutWeb.Schema.Types do field(:token_contract_address_hash, :address_hash) field(:transaction_hash, :full_hash) end + + def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash) end diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index bd3cc0fe5b..d7a1be661e 100644 --- a/apps/block_scout_web/mix.exs +++ b/apps/block_scout_web/mix.exs @@ -65,6 +65,8 @@ defmodule BlockScoutWeb.Mixfile do {:absinthe_phoenix, "~> 1.4"}, # Plug support for Absinthe {:absinthe_plug, "~> 1.4"}, + # Absinthe support for the Relay framework + {:absinthe_relay, "~> 1.4"}, {:bypass, "~> 0.8", only: :test}, {:credo, "0.10.2", only: [:dev, :test], runtime: false}, # For Absinthe to load data in batches 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 310a310d2b..02dff2fe9a 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 @@ -2,12 +2,12 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do use BlockScoutWeb.ConnCase describe "address field" do - test "with valid argument 'hashes', returns all expected fields", %{conn: conn} do + test "with valid argument 'hash', returns all expected fields", %{conn: conn} do address = insert(:address, fetched_coin_balance: 100) query = """ - query ($hashes: [AddressHash!]!) { - addresses(hashes: $hashes) { + query ($hash: AddressHash!) { + address(hash: $hash) { hash fetched_coin_balance fetched_coin_balance_block_number @@ -16,20 +16,18 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do } """ - variables = %{"hashes" => to_string(address.hash)} + variables = %{"hash" => to_string(address.hash)} conn = get(conn, "/graphql", query: query, variables: variables) assert json_response(conn, 200) == %{ "data" => %{ - "addresses" => [ - %{ - "hash" => to_string(address.hash), - "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), - "fetched_coin_balance_block_number" => address.fetched_coin_balance_block_number, - "contract_code" => nil - } - ] + "address" => %{ + "hash" => to_string(address.hash), + "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), + "fetched_coin_balance_block_number" => address.fetched_coin_balance_block_number, + "contract_code" => nil + } } } end @@ -38,24 +36,22 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do address = insert(:contract_address, fetched_coin_balance: 100) query = """ - query ($hashes: [AddressHash!]!) { - addresses(hashes: $hashes) { + query ($hash: AddressHash!) { + address(hash: $hash) { contract_code } } """ - variables = %{"hashes" => to_string(address.hash)} + variables = %{"hash" => to_string(address.hash)} conn = get(conn, "/graphql", query: query, variables: variables) assert json_response(conn, 200) == %{ "data" => %{ - "addresses" => [ - %{ - "contract_code" => to_string(address.contract_code) - } - ] + "address" => %{ + "contract_code" => to_string(address.contract_code) + } } } end @@ -65,8 +61,8 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do smart_contract = insert(:smart_contract, address_hash: address.hash) query = """ - query ($hashes: [AddressHash!]!) { - addresses(hashes: $hashes) { + query ($hash: AddressHash!) { + address(hash: $hash) { fetched_coin_balance smart_contract { name @@ -80,52 +76,50 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do } """ - variables = %{"hashes" => to_string(address.hash)} + variables = %{"hash" => to_string(address.hash)} conn = get(conn, "/graphql", query: query, variables: variables) assert json_response(conn, 200) == %{ "data" => %{ - "addresses" => [ - %{ - "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), - "smart_contract" => %{ - "name" => smart_contract.name, - "compiler_version" => smart_contract.compiler_version, - "optimization" => smart_contract.optimization, - "contract_source_code" => smart_contract.contract_source_code, - "abi" => Jason.encode!(smart_contract.abi), - "address_hash" => to_string(address.hash) - } + "address" => %{ + "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), + "smart_contract" => %{ + "name" => smart_contract.name, + "compiler_version" => smart_contract.compiler_version, + "optimization" => smart_contract.optimization, + "contract_source_code" => smart_contract.contract_source_code, + "abi" => Jason.encode!(smart_contract.abi), + "address_hash" => to_string(address.hash) } - ] + } } } end - test "errors for non-existent address hashes", %{conn: conn} do + test "errors for non-existent address hash", %{conn: conn} do address = build(:address) query = """ - query ($hashes: [AddressHash!]!) { - addresses(hashes: $hashes) { + query ($hash: AddressHash!) { + address(hash: $hash) { fetched_coin_balance } } """ - variables = %{"hashes" => [to_string(address.hash)]} + variables = %{"hash" => to_string(address.hash)} conn = get(conn, "/graphql", query: query, variables: variables) assert %{"errors" => [error]} = json_response(conn, 200) - assert error["message"] =~ ~s(Addresses not found.) + assert error["message"] =~ ~s(Address not found.) end - test "errors if argument 'hashes' is missing", %{conn: conn} do + test "errors if argument 'hash' is missing", %{conn: conn} do query = """ query { - addresses { + address { fetched_coin_balance } } @@ -136,50 +130,423 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do conn = get(conn, "/graphql", query: query, variables: variables) assert %{"errors" => [error]} = json_response(conn, 200) - assert error["message"] == ~s(In argument "hashes": Expected type "[AddressHash!]!", found null.) + assert error["message"] == ~s(In argument "hash": Expected type "AddressHash!", found null.) end - test "errors if argument 'hashes' is not a list of address hashes", %{conn: conn} do + test "errors if argument 'hash' is not a valid address hash", %{conn: conn} do query = """ - query ($hashes: [AddressHash!]!) { - addresses(hashes: $hashes) { + query ($hash: AddressHash!) { + address(hash: $hash) { fetched_coin_balance } } """ - variables = %{"hashes" => ["someInvalidHash"]} + variables = %{"hash" => "someInvalidHash"} conn = get(conn, "/graphql", query: query, variables: variables) assert %{"errors" => [error]} = json_response(conn, 200) - assert error["message"] =~ ~s(Argument "hashes" has invalid value) + assert error["message"] =~ ~s(Argument "hash" has invalid value) end + end + + describe "address transactions field" do + test "returns all expected transaction fields", %{conn: conn} do + address = insert(:address) - test "correlates complexity to size of 'hashes' argument", %{conn: conn} do - # max of 50 addresses with four fields of complexity 1 can be fetched - # per query: - # 50 * 4 = 200, which is equal to a max complexity of 200 - hashes = 51 |> build_list(:address) |> Enum.map(&to_string(&1.hash)) + transaction = insert(:transaction, from_address: address) query = """ - query ($hashes: [AddressHash!]!) { - addresses(hashes: $hashes) { - hash - fetched_coin_balance - fetched_coin_balance_block_number - contract_code + query ($hash: AddressHash!, $first: Int!) { + address(hash: $hash) { + transactions(first: $first) { + edges { + node { + hash + block_number + cumulative_gas_used + error + gas + gas_price + gas_used + index + input + nonce + r + s + status + v + value + from_address_hash + to_address_hash + created_contract_address_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(address.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "address" => %{ + "transactions" => %{ + "edges" => [ + %{ + "node" => %{ + "hash" => to_string(transaction.hash), + "block_number" => transaction.block_number, + "cumulative_gas_used" => nil, + "error" => transaction.error, + "gas" => to_string(transaction.gas), + "gas_price" => to_string(transaction.gas_price.value), + "gas_used" => nil, + "index" => transaction.index, + "input" => to_string(transaction.input), + "nonce" => to_string(transaction.nonce), + "r" => to_string(transaction.r), + "s" => to_string(transaction.s), + "status" => nil, + "v" => transaction.v, + "value" => to_string(transaction.value.value), + "from_address_hash" => to_string(transaction.from_address_hash), + "to_address_hash" => to_string(transaction.to_address_hash), + "created_contract_address_hash" => nil + } + } + ] + } + } + } + } + end + + test "with address with zero transactions", %{conn: conn} do + address = insert(:address) + + query = """ + query ($hash: AddressHash!, $first: Int!) { + address(hash: $hash) { + transactions(first: $first) { + edges { + node { + hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(address.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "address" => %{ + "transactions" => %{ + "edges" => [] + } + } + } + } + end + + test "transactions are ordered by descending block and index", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + + address = insert(:address) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + query = """ + query ($hash: AddressHash!, $first: Int!) { + address(hash: $hash) { + transactions(first: $first) { + edges { + node { + hash + block_number + index + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(address.hash), + "first" => 3 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + %{ + "data" => %{ + "address" => %{ + "transactions" => %{ + "edges" => transactions + } + } + } + } = json_response(conn, 200) + + block_number_and_index_order = + Enum.map(transactions, fn transaction -> + {transaction["node"]["block_number"], transaction["node"]["index"]} + end) + + assert block_number_and_index_order == Enum.sort(block_number_and_index_order, &(&1 >= &2)) + assert length(transactions) == 3 + assert Enum.all?(transactions, &(&1["node"]["block_number"] == third_block.number)) + end + + test "complexity correlates to 'first' or 'last' arguments", %{conn: conn} do + address = build(:address) + + query = """ + query ($hash: AddressHash!, $first: Int!) { + address(hash: $hash) { + transactions(first: $first) { + edges { + node { + hash + } + } + } } } """ - variables = %{"hashes" => hashes} + variables = %{ + "hash" => to_string(address.hash), + "first" => 67 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error1, error2, error3]} = json_response(conn, 200) + assert error1["message"] =~ ~s(Field transactions is too complex) + assert error2["message"] =~ ~s(Field address 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 of a 'count' argument. + + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + + address = insert(:address) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + query = """ + query ($hash: AddressHash!, $last: Int!, $count: Int!) { + address(hash: $hash) { + transactions(last: $last, count: $count) { + edges { + node { + hash + block_number + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(address.hash), + "last" => 3, + "count" => 9 + } conn = get(conn, "/graphql", query: query, variables: variables) - assert %{"errors" => [error1, error2]} = json_response(conn, 200) - assert error1["message"] =~ ~s(Field addresses is too complex) - assert error2["message"] =~ ~s(Operation is too complex) + %{ + "data" => %{ + "address" => %{ + "transactions" => %{ + "edges" => transactions + } + } + } + } = json_response(conn, 200) + + assert length(transactions) == 3 + assert Enum.all?(transactions, &(&1["node"]["block_number"] == first_block.number)) + end + + test "pagination support with 'first' and 'after' arguments", %{conn: conn} do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + + address = insert(:address) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + query1 = """ + query ($hash: AddressHash!, $first: Int!) { + address(hash: $hash) { + transactions(first: $first) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + hash + block_number + } + cursor + } + } + } + } + """ + + variables1 = %{ + "hash" => to_string(address.hash), + "first" => 3 + } + + conn = get(conn, "/graphql", query: query1, variables: variables1) + + %{"data" => %{"address" => %{"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"]["block_number"] == third_block.number)) + + last_cursor_page1 = + page1 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + query2 = """ + 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 + } + } + } + } + """ + + variables2 = %{ + "hash" => to_string(address.hash), + "first" => 3, + "after" => last_cursor_page1 + } + + conn = get(conn, "/graphql", query: query2, variables: variables2) + + %{"data" => %{"address" => %{"transactions" => page2}}} = json_response(conn, 200) + + assert page2["page_info"] == %{"has_next_page" => true, "has_previous_page" => true} + assert Enum.all?(page2["edges"], &(&1["node"]["block_number"] == second_block.number)) + + last_cursor_page2 = + page2 + |> Map.get("edges") + |> 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) + + %{"data" => %{"address" => %{"transactions" => page3}}} = json_response(conn, 200) + + assert page3["page_info"] == %{"has_next_page" => false, "has_previous_page" => true} + assert Enum.all?(page3["edges"], &(&1["node"]["block_number"] == first_block.number)) end end end diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/addresses_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/addresses_test.exs new file mode 100644 index 0000000000..3468e74693 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/schema/query/addresses_test.exs @@ -0,0 +1,185 @@ +defmodule BlockScoutWeb.Schema.Query.AddressesTest do + use BlockScoutWeb.ConnCase + + describe "addresses field" do + test "with valid argument 'hashes', returns all expected fields", %{conn: conn} do + address = insert(:address, fetched_coin_balance: 100) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + hash + fetched_coin_balance + fetched_coin_balance_block_number + contract_code + } + } + """ + + variables = %{"hashes" => to_string(address.hash)} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "addresses" => [ + %{ + "hash" => to_string(address.hash), + "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), + "fetched_coin_balance_block_number" => address.fetched_coin_balance_block_number, + "contract_code" => nil + } + ] + } + } + end + + test "with contract address, `contract_code` is serialized as expected", %{conn: conn} do + address = insert(:contract_address, fetched_coin_balance: 100) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + contract_code + } + } + """ + + variables = %{"hashes" => to_string(address.hash)} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "addresses" => [ + %{ + "contract_code" => to_string(address.contract_code) + } + ] + } + } + end + + test "smart_contract returns all expected fields", %{conn: conn} do + address = insert(:address, fetched_coin_balance: 100) + smart_contract = insert(:smart_contract, address_hash: address.hash) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + fetched_coin_balance + smart_contract { + name + compiler_version + optimization + contract_source_code + abi + address_hash + } + } + } + """ + + variables = %{"hashes" => to_string(address.hash)} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "addresses" => [ + %{ + "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), + "smart_contract" => %{ + "name" => smart_contract.name, + "compiler_version" => smart_contract.compiler_version, + "optimization" => smart_contract.optimization, + "contract_source_code" => smart_contract.contract_source_code, + "abi" => Jason.encode!(smart_contract.abi), + "address_hash" => to_string(address.hash) + } + } + ] + } + } + end + + test "errors for non-existent address hashes", %{conn: conn} do + address = build(:address) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + fetched_coin_balance + } + } + """ + + variables = %{"hashes" => [to_string(address.hash)]} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ ~s(Addresses not found.) + end + + test "errors if argument 'hashes' is missing", %{conn: conn} do + query = """ + query { + addresses { + fetched_coin_balance + } + } + """ + + variables = %{} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] == ~s(In argument "hashes": Expected type "[AddressHash!]!", found null.) + end + + test "errors if argument 'hashes' is not a list of address hashes", %{conn: conn} do + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + fetched_coin_balance + } + } + """ + + variables = %{"hashes" => ["someInvalidHash"]} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ ~s(Argument "hashes" has invalid value) + end + + test "correlates complexity to size of 'hashes' argument", %{conn: conn} do + # max of 50 addresses with four fields of complexity 1 can be fetched + # per query: + # 50 * 4 = 200, which is equal to a max complexity of 200 + hashes = 51 |> build_list(:address) |> Enum.map(&to_string(&1.hash)) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + hash + fetched_coin_balance + fetched_coin_balance_block_number + contract_code + } + } + """ + + variables = %{"hashes" => hashes} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error1, error2]} = json_response(conn, 200) + assert error1["message"] =~ ~s(Field addresses is too complex) + assert error2["message"] =~ ~s(Operation is too complex) + end + end +end 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 new file mode 100644 index 0000000000..6eac1cf81f --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs @@ -0,0 +1,35 @@ +defmodule BlockScoutWeb.Schema.Query.NodeTest do + use BlockScoutWeb.ConnCase + + describe "node field" do + test "with valid argument 'id' for a transaction", %{conn: conn} do + transaction = insert(: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) + + assert json_response(conn, 200) == %{ + "data" => %{ + "node" => %{ + "id" => id, + "hash" => to_string(transaction.hash) + } + } + } + end + end +end diff --git a/apps/explorer/lib/explorer/graphql.ex b/apps/explorer/lib/explorer/graphql.ex new file mode 100644 index 0000000000..50d3161861 --- /dev/null +++ b/apps/explorer/lib/explorer/graphql.ex @@ -0,0 +1,22 @@ +defmodule Explorer.GraphQL do + @moduledoc """ + The GraphQL context. + """ + + import Ecto.Query, + only: [ + order_by: 3, + or_where: 3, + where: 3 + ] + + alias Explorer.Chain.{Address, Hash, Transaction} + + 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) + |> where([transaction], transaction.to_address_hash == ^address_hash) + |> or_where([transaction], transaction.from_address_hash == ^address_hash) + |> or_where([transaction], transaction.created_contract_address_hash == ^address_hash) + end +end diff --git a/apps/explorer/test/explorer/graphql_test.exs b/apps/explorer/test/explorer/graphql_test.exs new file mode 100644 index 0000000000..91cc1c6c00 --- /dev/null +++ b/apps/explorer/test/explorer/graphql_test.exs @@ -0,0 +1,90 @@ +defmodule Explorer.GraphQLTest do + use Explorer.DataCase + + import Explorer.Factory + + alias Explorer.{GraphQL, Repo} + + describe "address_to_transactions_query/1" do + test "with address hash with zero transactions" do + result = + :address + |> insert() + |> GraphQL.address_to_transactions_query() + |> Repo.all() + + assert result == [] + end + + test "with matching 'to_address_hash'" do + address = insert(:address) + transaction = insert(:transaction, to_address: address) + insert(:transaction) + + [found_transaction] = + address + |> GraphQL.address_to_transactions_query() + |> Repo.all() + + assert found_transaction.hash == transaction.hash + end + + test "with matching 'from_address_hash'" do + address = insert(:address) + transaction = insert(:transaction, from_address: address) + insert(:transaction) + + [found_transaction] = + address + |> GraphQL.address_to_transactions_query() + |> Repo.all() + + assert found_transaction.hash == transaction.hash + end + + test "with matching 'created_contract_address_hash'" do + address = insert(:address) + transaction = insert(:transaction, created_contract_address: address) + insert(:transaction) + + [found_transaction] = + address + |> GraphQL.address_to_transactions_query() + |> Repo.all() + + assert found_transaction.hash == transaction.hash + end + + test "orders by descending block and index" do + first_block = insert(:block) + second_block = insert(:block) + third_block = insert(:block) + + address = insert(:address) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(second_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(third_block) + + 3 + |> insert_list(:transaction, from_address: address) + |> with_block(first_block) + + found_transactions = + address + |> GraphQL.address_to_transactions_query() + |> Repo.all() + + block_number_and_index_order = + Enum.map(found_transactions, fn transaction -> + {transaction.block_number, transaction.index} + end) + + assert block_number_and_index_order == Enum.sort(block_number_and_index_order, &(&1 >= &2)) + end + end +end diff --git a/mix.lock b/mix.lock index eb2d1cd627..a1d0a1c603 100644 --- a/mix.lock +++ b/mix.lock @@ -4,6 +4,7 @@ "absinthe": {:hex, :absinthe, "1.4.13", "81eb2ff41f1b62cd6e992955f62c22c042d1079b7936c27f5f7c2c806b8fc436", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "absinthe_phoenix": {:hex, :absinthe_phoenix, "1.4.3", "cea34e7ebbc9a252038c1f1164878ee86bcb108905fe462be77efacda15c1e70", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.2", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.10.5 or ~> 2.11", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poison, "~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "absinthe_plug": {:hex, :absinthe_plug, "1.4.6", "ac5d2d3d02acf52fda0f151b294017ab06e2ed1c6c15334e06aac82c94e36e08", [:mix], [{:absinthe, "~> 1.4.11", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "absinthe_relay": {:hex, :absinthe_relay, "1.4.4", "d0a6d8e71375a6026974d227456c8a73ea8eea7c7b00e698603ab5a96066c333", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "accept": {:hex, :accept, "0.3.3", "548ebb6fb2e8b0d170e75bb6123aea6ceecb0189bb1231eeadf52eac08384a97", [:rebar3], [], "hexpm"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.1.1", "6b5560e47a02196ce5f0ab3f1d8265db79a23868c137e973b27afef928ed8006", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, "benchee": {:hex, :benchee, "0.13.2", "30cd4ff5f593fdd218a9b26f3c24d580274f297d88ad43383afe525b1543b165", [:mix], [{:deep_merge, "~> 0.1", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"},