From af6a7358948e713dfebd4fa97972249d91948247 Mon Sep 17 00:00:00 2001 From: Felipe Renan Date: Fri, 26 Oct 2018 17:18:58 -0300 Subject: [PATCH 1/6] Ensure that token balances that gave errors will be scheduled --- apps/indexer/lib/indexer/token_balances.ex | 11 ++++---- .../test/indexer/token_balances_test.exs | 27 +++++++------------ 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/apps/indexer/lib/indexer/token_balances.ex b/apps/indexer/lib/indexer/token_balances.ex index 5e96615790..a92720eac1 100644 --- a/apps/indexer/lib/indexer/token_balances.ex +++ b/apps/indexer/lib/indexer/token_balances.ex @@ -29,7 +29,7 @@ defmodule Indexer.TokenBalances do token_balances |> Task.async_stream(&fetch_token_balance/1, on_timeout: :kill_task) |> Stream.map(&format_task_results/1) - |> Enum.filter(&ignore_request_with_timeouts/1) + |> Enum.filter(&ignore_request_with_errors/1) token_balances |> MapSet.new() @@ -70,11 +70,12 @@ defmodule Indexer.TokenBalances do |> TokenBalance.Fetcher.async_fetch() end - def format_task_results({:exit, :timeout}), do: {:error, :timeout} - def format_task_results({:ok, token_balance}), do: token_balance + defp format_task_results({:exit, :timeout}), do: {:error, :timeout} + defp format_task_results({:ok, token_balance}), do: token_balance - def ignore_request_with_timeouts({:error, :timeout}), do: false - def ignore_request_with_timeouts(_token_balance), do: true + defp ignore_request_with_errors({:error, :timeout}), do: false + defp ignore_request_with_errors(%{value: nil, value_fetched_at: nil, error: _error}), do: false + defp ignore_request_with_errors(_token_balance), do: true def log_fetching_errors(from, token_balances_params) do error_messages = diff --git a/apps/indexer/test/indexer/token_balances_test.exs b/apps/indexer/test/indexer/token_balances_test.exs index c59cd9e034..378559dc2e 100644 --- a/apps/indexer/test/indexer/token_balances_test.exs +++ b/apps/indexer/test/indexer/token_balances_test.exs @@ -45,28 +45,21 @@ defmodule Indexer.TokenBalancesTest do } = List.first(result) end - test "does not ignore calls that were returned with error" do - address = insert(:address) + test "ignores calls that gave errors to try fetch they again later" do + address = insert(:address, hash: "0x7113ffcb9c18a97da1b9cfc43e6cb44ed9165509") token = insert(:token, contract_address: build(:contract_address)) - address_hash_string = Hash.to_string(address.hash) - data = %{ - token_contract_address_hash: token.contract_address_hash, - address_hash: address_hash_string, - block_number: 1_000 - } + token_balances = [ + %{ + address_hash: to_string(address.hash), + block_number: 1_000, + token_contract_address_hash: to_string(token.contract_address_hash) + } + ] get_balance_from_blockchain_with_error() - {:ok, result} = TokenBalances.fetch_token_balances_from_blockchain([data]) - - assert %{ - value: nil, - token_contract_address_hash: token_contract_address_hash, - address_hash: address_hash, - block_number: 1_000, - value_fetched_at: nil - } = List.first(result) + assert TokenBalances.fetch_token_balances_from_blockchain(token_balances) == {:ok, []} end test "ignores results that raised :timeout" do From 233d539a006c7575db809d0fe3d12f565b3b1589 Mon Sep 17 00:00:00 2001 From: Felipe Renan Date: Fri, 26 Oct 2018 18:20:20 -0300 Subject: [PATCH 2/6] Add AddressCurrentTokenBalance schema --- .../chain/address/current_token_balance.ex | 60 +++++++++++++++++++ ..._create_address_current_token_balances.exs | 32 ++++++++++ apps/explorer/test/support/factory.ex | 11 ++++ 3 files changed, 103 insertions(+) create mode 100644 apps/explorer/lib/explorer/chain/address/current_token_balance.ex create mode 100644 apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs diff --git a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex new file mode 100644 index 0000000000..448ae7da34 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex @@ -0,0 +1,60 @@ +defmodule Explorer.Chain.Address.CurrentTokenBalance do + @moduledoc """ + Represents the current token balance from addresses according to the last block. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Explorer.Chain.{Address, Block, Hash, Token} + + @typedoc """ + * `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. + * `address_hash` - The address hash foreign key. + * `token` - The `t:Explorer.Chain.Token/0` so that the address has the balance. + * `token_contract_address_hash` - The contract address hash foreign key. + * `block_number` - The block's number that the transfer took place. + * `value` - The value that's represents the balance. + """ + @type t :: %__MODULE__{ + address: %Ecto.Association.NotLoaded{} | Address.t(), + address_hash: Hash.Address.t(), + token: %Ecto.Association.NotLoaded{} | Token.t(), + token_contract_address_hash: Hash.Address, + block_number: Block.block_number(), + inserted_at: DateTime.t(), + updated_at: DateTime.t(), + value: Decimal.t() | nil + } + + schema "address_current_token_balances" do + field(:value, :decimal) + field(:block_number, :integer) + field(:value_fetched_at, :utc_datetime) + + belongs_to(:address, Address, foreign_key: :address_hash, references: :hash, type: Hash.Address) + + belongs_to( + :token, + Token, + foreign_key: :token_contract_address_hash, + references: :contract_address_hash, + type: Hash.Address + ) + + timestamps() + end + + @optional_fields ~w(value value_fetched_at)a + @required_fields ~w(address_hash block_number token_contract_address_hash)a + @allowed_fields @optional_fields ++ @required_fields + + @doc false + def changeset(%__MODULE__{} = token_balance, attrs) do + token_balance + |> cast(attrs, @allowed_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:address_hash) + |> foreign_key_constraint(:token_contract_address_hash) + end +end diff --git a/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs b/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs new file mode 100644 index 0000000000..d497d1feda --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs @@ -0,0 +1,32 @@ +defmodule Explorer.Repo.Migrations.CreateAddressCurrentTokenBalances do + use Ecto.Migration + + def change do + create table(:address_current_token_balances) do + add(:address_hash, references(:addresses, column: :hash, type: :bytea), null: false) + add(:block_number, :bigint, null: false) + + add( + :token_contract_address_hash, + references(:tokens, column: :contract_address_hash, type: :bytea), + null: false + ) + + add(:value, :decimal, null: true) + add(:value_fetched_at, :utc_datetime, default: fragment("NULL"), null: true) + + timestamps(null: false, type: :utc_datetime) + end + + create(unique_index(:address_current_token_balances, ~w(address_hash token_contract_address_hash)a)) + + create( + index( + :address_current_token_balances, + [:value], + name: :address_current_token_balances_value, + where: "value IS NOT NULL" + ) + ) + end +end diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index f168091195..54f99731a8 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -13,6 +13,7 @@ defmodule Explorer.Factory do alias Explorer.Chain.{ Address, + Address.CurrentTokenBalance, Address.TokenBalance, Address.CoinBalance, Block, @@ -480,6 +481,16 @@ defmodule Explorer.Factory do } end + def address_current_token_balance_factory() do + %CurrentTokenBalance{ + address: build(:address), + token_contract_address_hash: insert(:token).contract_address_hash, + block_number: block_number(), + value: Enum.random(1..100_000), + value_fetched_at: DateTime.utc_now() + } + end + defmacrop left + right do quote do fragment("? + ?", unquote(left), unquote(right)) From ed183c988d454c7808423c74fa4a8a123c7b7d7d Mon Sep 17 00:00:00 2001 From: Felipe Renan Date: Fri, 26 Oct 2018 18:22:21 -0300 Subject: [PATCH 3/6] Import the current token balances --- apps/explorer/lib/explorer/chain/import.ex | 1 + .../import/address/current_token_balances.ex | 124 ++++++++++++++++++ .../address/current_token_balances_test.exs | 95 ++++++++++++++ .../test/explorer/chain/import_test.exs | 50 +++++++ .../lib/indexer/block/realtime/fetcher.ex | 1 + .../lib/indexer/token_balance/fetcher.ex | 8 +- 6 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex create mode 100644 apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index 3ba010e6be..28d62ee454 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -19,6 +19,7 @@ defmodule Explorer.Chain.Import do Import.Logs, Import.Tokens, Import.TokenTransfers, + Import.Address.CurrentTokenBalances, Import.Address.TokenBalances ] diff --git a/apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex b/apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex new file mode 100644 index 0000000000..11cf8cf460 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex @@ -0,0 +1,124 @@ +defmodule Explorer.Chain.Import.Address.CurrentTokenBalances do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Address.CurrentTokenBalance.t/0`. + """ + + require Ecto.Query + + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi} + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Import + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [CurrentTokenBalance.t()] + + @impl Import.Runner + def ecto_schema_module, do: CurrentTokenBalance + + @impl Import.Runner + def option_key, do: :address_current_token_balances + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :address_current_token_balances, fn _ -> + insert(changes_list, insert_options) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert([map()], %{ + optional(:on_conflict) => Import.Runner.on_conflict(), + required(:timeout) => timeout(), + required(:timestamps) => Import.timestamps() + }) :: + {:ok, [CurrentTokenBalance.t()]} + | {:error, [Changeset.t()]} + def insert(changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + {:ok, _} = + Import.insert_changes_list( + unique_token_balances(changes_list), + conflict_target: ~w(address_hash token_contract_address_hash)a, + on_conflict: on_conflict, + for: CurrentTokenBalance, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + end + + # Remove duplicated token balances based on `{address_hash, token_hash}` considering the last block + # to avoid `cardinality_violation` error in Postgres. This error happens when there are duplicated + # rows being inserted. + defp unique_token_balances(changes_list) do + changes_list + |> Enum.sort(&(&1.block_number > &2.block_number)) + |> Enum.uniq_by(fn %{address_hash: address_hash, token_contract_address_hash: token_hash} -> + {address_hash, token_hash} + end) + end + + defp default_on_conflict do + from( + current_token_balance in CurrentTokenBalance, + update: [ + set: [ + block_number: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.block_number ELSE ? END", + current_token_balance.block_number, + current_token_balance.block_number + ), + inserted_at: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.inserted_at ELSE ? END", + current_token_balance.block_number, + current_token_balance.inserted_at + ), + updated_at: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.updated_at ELSE ? END", + current_token_balance.block_number, + current_token_balance.updated_at + ), + value: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.value ELSE ? END", + current_token_balance.block_number, + current_token_balance.value + ), + value_fetched_at: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.value_fetched_at ELSE ? END", + current_token_balance.block_number, + current_token_balance.value_fetched_at + ) + ] + ] + ) + end +end diff --git a/apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs b/apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs new file mode 100644 index 0000000000..a78b7046ef --- /dev/null +++ b/apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs @@ -0,0 +1,95 @@ +defmodule Explorer.Chain.Import.Address.CurrentTokenBalancesTest do + use Explorer.DataCase + + alias Explorer.Chain.Import.Address.CurrentTokenBalances + + alias Explorer.Chain.{Address.CurrentTokenBalance} + + describe "insert/2" do + setup do + address = insert(:address, hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca") + token = insert(:token) + + insert_options = %{ + timeout: :infinity, + timestamps: %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + } + + %{address: address, token: token, insert_options: insert_options} + end + + test "inserts in the current token balances", %{address: address, token: token, insert_options: insert_options} do + changes = [ + %{ + address_hash: address.hash, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(100) + } + ] + + CurrentTokenBalances.insert(changes, insert_options) + + current_token_balances = + CurrentTokenBalance + |> Explorer.Repo.all() + |> Enum.count() + + assert current_token_balances == 1 + end + + test "considers the last block upserting", %{address: address, token: token, insert_options: insert_options} do + insert( + :address_current_token_balance, + address: address, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + value: 100 + ) + + changes = [ + %{ + address_hash: address.hash, + block_number: 2, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(200) + } + ] + + CurrentTokenBalances.insert(changes, insert_options) + + current_token_balance = Explorer.Repo.get_by(CurrentTokenBalance, address_hash: address.hash) + + assert current_token_balance.block_number == 2 + assert current_token_balance.value == Decimal.new(200) + end + + test "considers the last block when there are duplicated params", %{ + address: address, + token: token, + insert_options: insert_options + } do + changes = [ + %{ + address_hash: address.hash, + block_number: 4, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(200) + }, + %{ + address_hash: address.hash, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(100) + } + ] + + CurrentTokenBalances.insert(changes, insert_options) + + current_token_balance = Explorer.Repo.get_by(CurrentTokenBalance, address_hash: address.hash) + + assert current_token_balance.block_number == 4 + assert current_token_balance.value == Decimal.new(200) + end + end +end diff --git a/apps/explorer/test/explorer/chain/import_test.exs b/apps/explorer/test/explorer/chain/import_test.exs index ea4a485f6f..89c1c4f3eb 100644 --- a/apps/explorer/test/explorer/chain/import_test.exs +++ b/apps/explorer/test/explorer/chain/import_test.exs @@ -6,6 +6,7 @@ defmodule Explorer.Chain.ImportTest do alias Explorer.Chain.{ Address, Address.TokenBalance, + Address.CurrentTokenBalance, Block, Data, Log, @@ -395,6 +396,55 @@ defmodule Explorer.Chain.ImportTest do assert 3 == count end + test "inserts a current_token_balance" do + params = %{ + addresses: %{ + params: [ + %{hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca"}, + %{hash: "0x515c09c5bba1ed566b02a5b0599ec5d5d0aee73d"}, + %{hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"} + ], + timeout: 5 + }, + tokens: %{ + on_conflict: :nothing, + params: [ + %{ + contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + type: "ERC-20" + } + ], + timeout: 5 + }, + address_current_token_balances: %{ + params: [ + %{ + address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + token_contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + block_number: "37", + value: 200 + }, + %{ + address_hash: "0x515c09c5bba1ed566b02a5b0599ec5d5d0aee73d", + token_contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + block_number: "37", + value: 100 + } + ], + timeout: 5 + } + } + + Import.all(params) + + count = + CurrentTokenBalance + |> Explorer.Repo.all() + |> Enum.count() + + assert count == 2 + end + test "with empty map" do assert {:ok, %{}} == Import.all(%{}) end diff --git a/apps/indexer/lib/indexer/block/realtime/fetcher.ex b/apps/indexer/lib/indexer/block/realtime/fetcher.ex index f84e19ffc1..88682c149a 100644 --- a/apps/indexer/lib/indexer/block/realtime/fetcher.ex +++ b/apps/indexer/lib/indexer/block/realtime/fetcher.ex @@ -107,6 +107,7 @@ defmodule Indexer.Block.Realtime.Fetcher do |> put_in([:addresses, :params], balances_addresses_params) |> put_in([:blocks, :params, Access.all(), :consensus], true) |> put_in([Access.key(:address_coin_balances, %{}), :params], balances_params) + |> put_in([Access.key(:address_current_token_balances, %{}), :params], address_token_balances) |> put_in([Access.key(:address_token_balances), :params], address_token_balances) |> put_in([Access.key(:internal_transactions, %{}), :params], internal_transactions_params), {:ok, imported} = ok <- Chain.import(chain_import_options) do diff --git a/apps/indexer/lib/indexer/token_balance/fetcher.ex b/apps/indexer/lib/indexer/token_balance/fetcher.ex index 19df1853f9..f8cf8e65a6 100644 --- a/apps/indexer/lib/indexer/token_balance/fetcher.ex +++ b/apps/indexer/lib/indexer/token_balance/fetcher.ex @@ -80,7 +80,13 @@ defmodule Indexer.TokenBalance.Fetcher do end def import_token_balances(token_balances_params) do - case Chain.import(%{address_token_balances: %{params: token_balances_params}, timeout: :infinity}) do + import_params = %{ + address_token_balances: %{params: token_balances_params}, + address_current_token_balances: %{params: token_balances_params}, + timeout: :infinity + } + + case Chain.import(import_params) do {:ok, _} -> :ok From 3f7dd2bcdd40dcdaefadcabcc54d6e83ff3716e1 Mon Sep 17 00:00:00 2001 From: Felipe Renan Date: Tue, 30 Oct 2018 15:20:24 -0300 Subject: [PATCH 4/6] Move Token holder's query to Address.CurrentTokenBalance --- .../lib/block_scout_web/chain.ex | 4 +- .../tokens/holder_controller_test.exs | 8 +- .../features/viewing_tokens_test.exs | 2 +- apps/explorer/lib/explorer/chain.ex | 3 +- .../chain/address/current_token_balance.ex | 48 ++++++ .../explorer/chain/address/token_balance.ex | 53 +----- .../address/current_token_balance_test.exs | 149 ++++++++++++++++ apps/explorer/test/explorer/chain_test.exs | 161 ++---------------- 8 files changed, 218 insertions(+), 210 deletions(-) create mode 100644 apps/explorer/test/explorer/chain/address/current_token_balance_test.exs diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex index bb3511a1b3..08e4e5c321 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.Chain do alias Explorer.Chain.{ Address, - Address.TokenBalance, + Address.CurrentTokenBalance, Block, InternalTransaction, Log, @@ -198,7 +198,7 @@ defmodule BlockScoutWeb.Chain do %{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at_datetime} end - defp paging_params(%TokenBalance{address_hash: address_hash, value: value}) do + defp paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) do %{"address_hash" => to_string(address_hash), "value" => Decimal.to_integer(value)} end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs index 6ce8d7915b..66c8ce9de4 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs @@ -22,7 +22,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do insert_list( 2, - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash ) @@ -43,7 +43,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do 1..50 |> Enum.map( &insert( - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: &1 + 1000 ) @@ -52,7 +52,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do token_balance = insert( - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: 50000 ) @@ -78,7 +78,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do Enum.each( 1..51, &insert( - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: &1 + 1000 ) diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs index 8a5d769e62..06f1255964 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs @@ -9,7 +9,7 @@ defmodule BlockScoutWeb.ViewingTokensTest do insert_list( 2, - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash ) diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 2140e36b14..51eac0ed1a 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -23,6 +23,7 @@ defmodule Explorer.Chain do alias Explorer.Chain.{ Address, Address.CoinBalance, + Address.CurrentTokenBalance, Address.TokenBalance, Block, Data, @@ -2070,7 +2071,7 @@ defmodule Explorer.Chain do @spec fetch_token_holders_from_token_hash(Hash.Address.t(), [paging_options]) :: [TokenBalance.t()] def fetch_token_holders_from_token_hash(contract_address_hash, options) do contract_address_hash - |> TokenBalance.token_holders_ordered_by_value(options) + |> CurrentTokenBalance.token_holders_ordered_by_value(options) |> Repo.all() end diff --git a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex index 448ae7da34..21f20e1f8c 100644 --- a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex @@ -5,9 +5,13 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do use Ecto.Schema import Ecto.Changeset + import Ecto.Query, only: [from: 2, limit: 2, order_by: 3, preload: 2, where: 3] + alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.{Address, Block, Hash, Token} + @default_paging_options %PagingOptions{page_size: 50} + @typedoc """ * `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. * `address_hash` - The address hash foreign key. @@ -57,4 +61,48 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do |> foreign_key_constraint(:address_hash) |> foreign_key_constraint(:token_contract_address_hash) end + + {:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") + @burn_address_hash burn_address_hash + + @doc """ + Builds an `Ecto.Query` to fetch the token holders from the given token contract address hash. + + The Token Holders are the addresses that own a positive amount of the Token. So this query is + considering the following conditions: + + * The token balance from the last block. + * Balances greater than 0. + * Excluding the burn address (0x0000000000000000000000000000000000000000). + + """ + def token_holders_ordered_by_value(token_contract_address_hash, options \\ []) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + token_contract_address_hash + |> token_holders_query + |> preload(:address) + |> order_by([tb], desc: :value) + |> page_token_balances(paging_options) + |> limit(^paging_options.page_size) + end + + defp token_holders_query(token_contract_address_hash) do + from( + tb in __MODULE__, + where: tb.token_contract_address_hash == ^token_contract_address_hash, + where: tb.address_hash != ^@burn_address_hash, + where: tb.value > 0 + ) + end + + defp page_token_balances(query, %PagingOptions{key: nil}), do: query + + defp page_token_balances(query, %PagingOptions{key: {value, address_hash}}) do + where( + query, + [tb], + tb.value < ^value or (tb.value == ^value and tb.address_hash < ^address_hash) + ) + end end diff --git a/apps/explorer/lib/explorer/chain/address/token_balance.ex b/apps/explorer/lib/explorer/chain/address/token_balance.ex index 2b1c4f64b8..383eb24ec2 100644 --- a/apps/explorer/lib/explorer/chain/address/token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/token_balance.ex @@ -5,14 +5,12 @@ defmodule Explorer.Chain.Address.TokenBalance do use Ecto.Schema import Ecto.Changeset - import Ecto.Query, only: [from: 2, limit: 2, where: 3, subquery: 1, order_by: 3, preload: 2] + import Ecto.Query, only: [from: 2, subquery: 1] - alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain alias Explorer.Chain.Address.TokenBalance alias Explorer.Chain.{Address, Block, Hash, Token} - @default_paging_options %PagingOptions{page_size: 50} - @typedoc """ * `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. * `address_hash` - The address hash foreign key. @@ -84,43 +82,6 @@ defmodule Explorer.Chain.Address.TokenBalance do from(tb in subquery(query), where: tb.value > 0, preload: :token) end - @doc """ - Builds an `Ecto.Query` to fetch the token holders from the given token contract address hash. - - The Token Holders are the addresses that own a positive amount of the Token. So this query is - considering the following conditions: - - * The token balance from the last block. - * Balances greater than 0. - * Excluding the burn address (0x0000000000000000000000000000000000000000). - - """ - def token_holders_from_token_hash(token_contract_address_hash) do - query = token_holders_query(token_contract_address_hash) - - from(tb in subquery(query), where: tb.value > 0) - end - - def token_holders_ordered_by_value(token_contract_address_hash, options) do - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - token_contract_address_hash - |> token_holders_from_token_hash() - |> order_by([tb], desc: tb.value, desc: tb.address_hash) - |> preload(:address) - |> page_token_balances(paging_options) - |> limit(^paging_options.page_size) - end - - defp token_holders_query(contract_address_hash) do - from( - tb in TokenBalance, - distinct: :address_hash, - where: tb.token_contract_address_hash == ^contract_address_hash and tb.address_hash != ^@burn_address_hash, - order_by: [desc: :block_number] - ) - end - @doc """ Builds an `Ecto.Query` to group all tokens with their number of holders. """ @@ -144,16 +105,6 @@ defmodule Explorer.Chain.Address.TokenBalance do ) end - defp page_token_balances(query, %PagingOptions{key: nil}), do: query - - defp page_token_balances(query, %PagingOptions{key: {value, address_hash}}) do - where( - query, - [tb], - tb.value < ^value or (tb.value == ^value and tb.address_hash < ^address_hash) - ) - end - @doc """ Builds an `Ecto.Query` to fetch the unfetched token balances. diff --git a/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs new file mode 100644 index 0000000000..f02538f96b --- /dev/null +++ b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs @@ -0,0 +1,149 @@ +defmodule Explorer.Chain.Address.CurrentTokenBalanceTest do + use Explorer.DataCase + + alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.Chain.Token + alias Explorer.Chain.Address.CurrentTokenBalance + + describe "token_holders_ordered_by_value/2" do + test "returns the last value for each address" do + %Token{contract_address_hash: contract_address_hash} = insert(:token) + address_a = insert(:address) + address_b = insert(:address) + + insert( + :address_current_token_balance, + address: address_a, + token_contract_address_hash: contract_address_hash, + value: 5000 + ) + + insert( + :address_current_token_balance, + address: address_b, + block_number: 1001, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + token_holders_count = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + |> Enum.count() + + assert token_holders_count == 2 + end + + test "sort by the highest value" do + %Token{contract_address_hash: contract_address_hash} = insert(:token) + address_a = insert(:address) + address_b = insert(:address) + address_c = insert(:address) + + insert( + :address_current_token_balance, + address: address_a, + token_contract_address_hash: contract_address_hash, + value: 5000 + ) + + insert( + :address_current_token_balance, + address: address_b, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + insert( + :address_current_token_balance, + address: address_c, + token_contract_address_hash: contract_address_hash, + value: 15000 + ) + + token_holders_values = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + |> Enum.map(&Decimal.to_integer(&1.value)) + + assert token_holders_values == [15_000, 5_000, 4_000] + end + + test "returns only token balances that have value greater than 0" do + %Token{contract_address_hash: contract_address_hash} = insert(:token) + + insert( + :address_current_token_balance, + token_contract_address_hash: contract_address_hash, + value: 0 + ) + + result = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + + assert result == [] + end + + test "ignores the burn address" do + {:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") + + burn_address = insert(:address, hash: burn_address_hash) + + %Token{contract_address_hash: contract_address_hash} = insert(:token) + + insert( + :address_current_token_balance, + address: burn_address, + token_contract_address_hash: contract_address_hash, + value: 1000 + ) + + result = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + + assert result == [] + end + + test "paginates the result by value and different address" do + address_a = build(:address, hash: "0xcb2cf1fd3199584ac5faa16c6aca49472dc6495a") + address_b = build(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") + + %Token{contract_address_hash: contract_address_hash} = insert(:token) + + first_page = + insert( + :address_current_token_balance, + address: address_a, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + second_page = + insert( + :address_current_token_balance, + address: address_b, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + paging_options = %PagingOptions{ + key: {first_page.value, first_page.address_hash}, + page_size: 2 + } + + result_paginated = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value(paging_options: paging_options) + |> Repo.all() + |> Enum.map(& &1.address_hash) + + assert result_paginated == [second_page.address_hash] + end + end +end diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 414a54e217..0634f02bf0 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -2956,173 +2956,32 @@ defmodule Explorer.ChainTest do end describe "fetch_token_holders_from_token_hash/2" do - test "returns the last value for each address" do + test "returns the token holders" do %Token{contract_address_hash: contract_address_hash} = insert(:token) - address = insert(:address) + address_a = insert(:address) + address_b = insert(:address) insert( - :token_balance, - address: address, - block_number: 1000, + :address_current_token_balance, + address: address_a, token_contract_address_hash: contract_address_hash, value: 5000 ) insert( - :token_balance, - block_number: 1001, - token_contract_address_hash: contract_address_hash, - value: 4000 - ) - - insert( - :token_balance, - address: address, - block_number: 1002, - token_contract_address_hash: contract_address_hash, - value: 2000 - ) - - values = - contract_address_hash - |> Chain.fetch_token_holders_from_token_hash([]) - |> Enum.map(&Decimal.to_integer(&1.value)) - - assert values == [4000, 2000] - end - - test "sort by the highest value" do - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - block_number: 1000, - token_contract_address_hash: contract_address_hash, - value: 2000 - ) - - insert( - :token_balance, + :address_current_token_balance, + address: address_b, block_number: 1001, token_contract_address_hash: contract_address_hash, - value: 1000 - ) - - insert( - :token_balance, - block_number: 1002, - token_contract_address_hash: contract_address_hash, value: 4000 ) - insert( - :token_balance, - block_number: 1002, - token_contract_address_hash: contract_address_hash, - value: 3000 - ) - - values = + token_holders_count = contract_address_hash |> Chain.fetch_token_holders_from_token_hash([]) - |> Enum.map(&Decimal.to_integer(&1.value)) - - assert values == [4000, 3000, 2000, 1000] - end - - test "returns only token balances that have value" do - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - token_contract_address_hash: contract_address_hash, - value: 0 - ) - - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] - end - - test "returns an empty list when there are no address with value greater than 0" do - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert(:token_balance, value: 1000) - - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] - end - - test "ignores the burn address" do - {:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") - - burn_address = insert(:address, hash: burn_address_hash) - - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - address: burn_address, - token_contract_address_hash: contract_address_hash, - value: 1000 - ) - - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] - end - - test "paginates the result by value and different address" do - address_a = build(:address, hash: "0xcb2cf1fd3199584ac5faa16c6aca49472dc6495a") - address_b = build(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") - - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - first_page = - insert( - :token_balance, - address: address_a, - token_contract_address_hash: contract_address_hash, - value: 4000 - ) - - second_page = - insert( - :token_balance, - address: address_b, - token_contract_address_hash: contract_address_hash, - value: 4000 - ) - - paging_options = %PagingOptions{ - key: {first_page.value, first_page.address_hash}, - page_size: 2 - } - - holders_paginated = - contract_address_hash - |> Chain.fetch_token_holders_from_token_hash(paging_options: paging_options) - |> Enum.map(& &1.address_hash) - - assert holders_paginated == [second_page.address_hash] - end - - test "considers the last block only if it has value" do - address = insert(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - address: address, - block_number: 1000, - token_contract_address_hash: contract_address_hash, - value: 5000 - ) - - insert( - :token_balance, - address: address, - block_number: 1002, - token_contract_address_hash: contract_address_hash, - value: 0 - ) + |> Enum.count() - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] + assert token_holders_count == 2 end end From a660b662fd4056d1f1396dae0cb1416ba2819259 Mon Sep 17 00:00:00 2001 From: Sebastian Abondano Date: Wed, 31 Oct 2018 16:12:36 -0400 Subject: [PATCH 5/6] Adds hash to GraphQL block object Why: * For GraphQL API consumers to be able to fetch the hash for a given block. Sample usage: ``` query ($number: Int!) { block(number: $number) { hash } } ``` * Issue link: n/a This change addresses the need by: * Editing the block object in `BlockScoutWeb.Schema.Types` to include a hash field of type `:full_hash`. --- apps/block_scout_web/lib/block_scout_web/schema/types.ex | 1 + .../test/block_scout_web/schema/query/block_test.exs | 2 ++ 2 files changed, 3 insertions(+) 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 66da7a55d4..14d2cb3c43 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 @@ -12,6 +12,7 @@ defmodule BlockScoutWeb.Schema.Types do structure that they form is called a "blockchain". """ object :block do + field(:hash, :full_hash) field(:consensus, :boolean) field(:difficulty, :decimal) field(:gas_limit, :decimal) diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs index a635614f27..432c5ebf2a 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.Schema.Query.BlockTest do query = """ query ($number: Int!) { block(number: $number) { + hash consensus difficulty gas_limit @@ -31,6 +32,7 @@ defmodule BlockScoutWeb.Schema.Query.BlockTest do assert json_response(conn, 200) == %{ "data" => %{ "block" => %{ + "hash" => to_string(block.hash), "consensus" => block.consensus, "difficulty" => to_string(block.difficulty), "gas_limit" => to_string(block.gas_limit), From 67c68b6f416453eea691be66bbf9ef348f8449c2 Mon Sep 17 00:00:00 2001 From: Sebastian Abondano Date: Wed, 31 Oct 2018 14:54:46 -0400 Subject: [PATCH 6/6] GraphQL API query to get addresses by hash Why: * We'd like to support GraphQL API queries for getting single or multiple addresses by hashes. API users could use this instead of the `balance` and `balancemulti` actions on the RPC API. Sample document: ``` query ($hashes: [AddressHash!]!) { addresses(hashes: $hashes) { hash fetched_coin_balance fetched_coin_balance_block_number contract_code } } ``` * Issue link: n/a This change addresses the need by: * Creating `BlockScoutWeb.Resolvers.Address` with a single resolver function that gets addresses by a list of hashes. * Adding `:data` scalar to `BlockScoutWeb.Schema.Scalars`. * Adding `address` object type to `BlockScoutWeb.Schema.Types`. Uses new `:data` scalar mentioned above. * Adding `addresses` field to query in `BlockScoutWeb.Schema`. Uses the new `address` object type and the resolver function mentioned above. * Editing `Abinthe.Plug` and `GraphiQL` in router to analyze complexity and set `max_complexity` to 50. --- .../lib/block_scout_web/resolvers/address.ex | 12 ++ .../lib/block_scout_web/router.ex | 10 +- .../lib/block_scout_web/schema.ex | 9 +- .../lib/block_scout_web/schema/scalars.ex | 19 ++- .../lib/block_scout_web/schema/types.ex | 10 ++ .../schema/query/address_test.exs | 142 ++++++++++++++++++ 6 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 apps/block_scout_web/lib/block_scout_web/resolvers/address.ex create mode 100644 apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs 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 new file mode 100644 index 0000000000..f731f1c7cd --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex @@ -0,0 +1,12 @@ +defmodule BlockScoutWeb.Resolvers.Address do + @moduledoc false + + alias Explorer.Chain + + def get_by(_, %{hashes: hashes}, _) do + case Chain.hashes_to_addresses(hashes) do + [] -> {:error, "Addresses not found."} + result -> {:ok, result} + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex index b249f03174..dcf9717329 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -38,12 +38,18 @@ defmodule BlockScoutWeb.Router do }) end - forward("/graphql", Absinthe.Plug, schema: BlockScoutWeb.Schema) + forward("/graphql", Absinthe.Plug, + schema: BlockScoutWeb.Schema, + analyze_complexity: true, + max_complexity: 50 + ) forward("/graphiql", Absinthe.Plug.GraphiQL, schema: BlockScoutWeb.Schema, interface: :playground, - socket: BlockScoutWeb.UserSocket + socket: BlockScoutWeb.UserSocket, + analyze_complexity: true, + max_complexity: 50 ) scope "/", BlockScoutWeb do 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 a50d29f3b9..03b2ee7c8e 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema.ex @@ -3,11 +3,18 @@ defmodule BlockScoutWeb.Schema do use Absinthe.Schema - alias BlockScoutWeb.Resolvers.{Block, Transaction} + alias BlockScoutWeb.Resolvers.{Address, Block, Transaction} import_types(BlockScoutWeb.Schema.Types) query do + @desc "Gets addresses by address hash." + field :addresses, list_of(:address) do + arg(:hashes, non_null(list_of(non_null(:address_hash)))) + resolve(&Address.get_by/3) + complexity(fn %{hashes: hashes}, child_complexity -> length(hashes) * child_complexity end) + end + @desc "Gets a block by number." field :block, :block do arg(:number, non_null(:integer)) 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 5f71182cab..de42a24512 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 @@ -3,7 +3,7 @@ defmodule BlockScoutWeb.Schema.Scalars do use Absinthe.Schema.Notation - alias Explorer.Chain.{Hash, Wei} + alias Explorer.Chain.{Data, Hash, Wei} alias Explorer.Chain.Hash.{Address, Full, Nonce} @desc """ @@ -24,6 +24,23 @@ defmodule BlockScoutWeb.Schema.Scalars do serialize(&to_string/1) end + @desc """ + An unpadded hexadecimal number with 0 or more digits. Each pair of digits + maps directly to a byte in the underlying binary representation. When + interpreted as a number, it should be treated as big-endian. + """ + scalar :data do + parse(fn + %Absinthe.Blueprint.Input.String{value: value} -> + Data.cast(value) + + _ -> + :error + end) + + serialize(&to_string/1) + end + @desc """ A 32-byte [KECCAK-256](https://en.wikipedia.org/wiki/SHA-3) 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 14d2cb3c43..b88306585b 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,6 +6,16 @@ defmodule BlockScoutWeb.Schema.Types do import_types(Absinthe.Type.Custom) import_types(BlockScoutWeb.Schema.Scalars) + @desc """ + A stored representation of a Web3 address. + """ + object :address do + field(:hash, :address_hash) + field(:fetched_coin_balance, :wei) + field(:fetched_coin_balance_block_number, :integer) + field(:contract_code, :data) + end + @desc """ A package of data that contains zero or more transactions, the hash of the previous block ("parent"), and optionally other data. Because each block (except for the initial "genesis block") points to the previous block, the data 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 new file mode 100644 index 0000000000..4854cf4738 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs @@ -0,0 +1,142 @@ +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 + 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 "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 12 addresses with four fields of complexity 1 can be fetched + # per query: + # 12 * 4 = 48, which is less than a max complexity of 50 + hashes = 13 |> 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