GraphQL API token_transfers query

Why:

* For GraphQL API users to be able to fetch token transfer events. RPC
API users could use this instead of the `tokentx` RPC API action.

  Example usage:
  1.
  ```
  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
        }
      }
    }
  }
  ```

  2. with pagination support via [Relay Cursor
Connections](https://facebook.github.io/relay/graphql/connections.htm)
  ```
  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 {
          amount
          from_address_hash
          to_address_hash
        }
        cursor
      }
    }
  }
  ```
* Issue link: n/a

This change addresses the need by:

* Editing the node interface and node field in `BlockScoutWeb.Schema` to
support token transfers.
* Adding `token_transfers` connection field to root query object in
`BlockScoutWeb.Schema`
* Making `token_transfer` object types a node type and adding a few new
fields to it, in `BlockScoutWeb.Schema.Types`.
* Creating `BlockScoutWeb.Resolvers.TokenTransfer` with resolver
functions for getting token transfers.
* Adding `get_token_transfer/1` and `list_token_transfers_query/1` to
`Explorer.GraphQL`. These two functions are used by the new resolver
functions mentioned above.
* Editing RPC API docs to reference the new GraphQL query field added in
this commit.
pull/1176/head
Sebastian Abondano 6 years ago
parent 9d620f2530
commit fa09f399d8
  1. 3
      apps/block_scout_web/lib/block_scout_web/etherscan.ex
  2. 24
      apps/block_scout_web/lib/block_scout_web/resolvers/token_transfer.ex
  3. 40
      apps/block_scout_web/lib/block_scout_web/schema.ex
  4. 26
      apps/block_scout_web/lib/block_scout_web/schema/types.ex
  5. 67
      apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs
  6. 326
      apps/block_scout_web/test/block_scout_web/schema/query/token_transfers_test.exs
  7. 29
      apps/explorer/lib/explorer/graphql.ex
  8. 125
      apps/explorer/test/explorer/graphql_test.exs

@ -1087,7 +1087,8 @@ defmodule BlockScoutWeb.Etherscan do
@account_tokentx_action %{ @account_tokentx_action %{
name: "tokentx", 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: [ required_params: [
%{ %{
key: "address", key: "address",

@ -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

@ -11,23 +11,28 @@ defmodule BlockScoutWeb.Schema do
Address, Address,
Block, Block,
InternalTransaction, InternalTransaction,
TokenTransfer,
Transaction Transaction
} }
alias Explorer.Chain alias Explorer.Chain
alias Explorer.Chain.InternalTransaction, as: ExplorerChainInternalTransaction alias Explorer.Chain.InternalTransaction, as: ExplorerChainInternalTransaction
alias Explorer.Chain.TokenTransfer, as: ExplorerChainTokenTransfer
alias Explorer.Chain.Transaction, as: ExplorerChainTransaction alias Explorer.Chain.Transaction, as: ExplorerChainTransaction
import_types(BlockScoutWeb.Schema.Types) import_types(BlockScoutWeb.Schema.Types)
node interface do node interface do
resolve_type(fn resolve_type(fn
%ExplorerChainTransaction{}, _ ->
:transaction
%ExplorerChainInternalTransaction{}, _ -> %ExplorerChainInternalTransaction{}, _ ->
:internal_transaction :internal_transaction
%ExplorerChainTokenTransfer{}, _ ->
:token_transfer
%ExplorerChainTransaction{}, _ ->
:transaction
_, _ -> _, _ ->
nil nil
end) end)
@ -36,15 +41,20 @@ defmodule BlockScoutWeb.Schema do
query do query do
node field do node field do
resolve(fn 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}, _ -> %{type: :internal_transaction, id: id}, _ ->
%{"transaction_hash" => transaction_hash_string, "index" => index} = Jason.decode!(id) %{"transaction_hash" => transaction_hash_string, "index" => index} = Jason.decode!(id)
{:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string) {:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string)
InternalTransaction.get_by(%{transaction_hash: transaction_hash, index: index}) 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"} {:error, "Unknown node"}
end) end)
@ -69,6 +79,22 @@ defmodule BlockScoutWeb.Schema do
resolve(&Block.get_by/3) resolve(&Block.get_by/3)
end 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." @desc "Gets a transaction by hash."
field :transaction, :transaction do field :transaction, :transaction do
arg(:hash, non_null(:full_hash)) arg(:hash, non_null(:full_hash))

@ -16,6 +16,7 @@ defmodule BlockScoutWeb.Schema.Types do
connection(node_type: :transaction) connection(node_type: :transaction)
connection(node_type: :internal_transaction) connection(node_type: :internal_transaction)
connection(node_type: :token_transfer)
@desc """ @desc """
A stored representation of a Web3 address. A stored representation of a Web3 address.
@ -105,6 +106,20 @@ defmodule BlockScoutWeb.Schema.Types do
field(:address_hash, :address_hash) field(:address_hash, :address_hash)
end 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 """ @desc """
Models a Web3 transaction. Models a Web3 transaction.
""" """
@ -142,15 +157,8 @@ defmodule BlockScoutWeb.Schema.Types do
end end
end end
@desc """ def token_transfer_id_fetcher(%{transaction_hash: transaction_hash, log_index: log_index}, _) do
Represents a token transfer between addresses. Jason.encode!(%{transaction_hash: to_string(transaction_hash), log_index: log_index})
"""
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)
end end
def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash) def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash)

@ -126,5 +126,72 @@ defmodule BlockScoutWeb.Schema.Query.NodeTest do
assert error["message"] == "Internal transaction not found." assert error["message"] == "Internal transaction not found."
end 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
end end

@ -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

@ -15,6 +15,7 @@ defmodule Explorer.GraphQL do
Address, Address,
Hash, Hash,
InternalTransaction, InternalTransaction,
TokenTransfer,
Transaction Transaction
} }
@ -67,4 +68,32 @@ defmodule Explorer.GraphQL do
Chain.where_transaction_has_multiple_internal_transactions(query) Chain.where_transaction_has_multiple_internal_transactions(query)
end 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 end

@ -181,4 +181,129 @@ defmodule Explorer.GraphQLTest do
# #
# These two requirements are tested in `Explorer.ChainTest`. # These two requirements are tested in `Explorer.ChainTest`.
end 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 end

Loading…
Cancel
Save