From 07a0de6aed218e4e3b8b0265d28912551d7e5f44 Mon Sep 17 00:00:00 2001 From: Sebastian Abondano Date: Thu, 27 Sep 2018 15:29:44 -0400 Subject: [PATCH] account#tokenlist API endpoint Why: * For API users to be able to get a list of tokens owned by a given address. Example usage: ``` /api?module=account&action=tokenlist&address={addressHash} ``` * Issue link: https://github.com/poanetwork/blockscout/issues/814 This change addresses the need by: * Creating `Explorer.Etherscan.list_tokens/1` to fetch tokens owned by a given address. * Creating `API.RPC.AddressController.tokenlist/2` action to process requests to `account#tokenlist` API endpoint. * Editing `API.RPC.AddressView` to render token lists as required. * Adding documentation data for the new API endpoint mentioned above. Documentation data lives in `BlockScoutWeb.Etherscan`. --- .../controllers/api/rpc/address_controller.ex | 24 ++++ .../lib/block_scout_web/etherscan.ex | 67 ++++++++++ .../views/api/rpc/address_view.ex | 15 +++ .../api/rpc/address_controller_test.exs | 126 ++++++++++++++++++ apps/explorer/lib/explorer/etherscan.ex | 25 ++++ .../explorer/test/explorer/etherscan_test.exs | 82 ++++++++++++ 6 files changed, 339 insertions(+) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex index d9341a7b42..78dd4b9457 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex @@ -110,6 +110,23 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end + def tokenlist(conn, params) do + with {:address_param, {:ok, address_param}} <- fetch_address(params), + {:format, {:ok, address_hash}} <- to_address_hash(address_param), + {:ok, token_list} <- list_tokens(address_hash) do + render(conn, :token_list, %{token_list: token_list}) + else + {:address_param, :error} -> + render(conn, :error, error: "Query parameter address is required") + + {:format, :error} -> + render(conn, :error, error: "Invalid address format") + + {:error, :not_found} -> + render(conn, :error, error: "No tokens found", data: []) + end + end + def getminedblocks(conn, params) do options = put_pagination_options(%{}, params) @@ -364,4 +381,11 @@ defmodule BlockScoutWeb.API.RPC.AddressController do token_balance -> token_balance.value end end + + defp list_tokens(address_hash) do + case Etherscan.list_tokens(address_hash) do + [] -> {:error, :not_found} + token_list -> {:ok, token_list} + end + end end 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 8e2d07a757..43c8174401 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -142,6 +142,20 @@ defmodule BlockScoutWeb.Etherscan do "result" => nil } + @account_tokenlist_example_value %{ + "status" => "1", + "message" => "OK", + "result" => [ + %{ + "balance" => "135499", + "contractAddress" => "0x0000000000000000000000000000000000000000", + "name" => "Example Token", + "decimals" => "18", + "symbol" => "ET" + } + ] + } + @account_getminedblocks_example_value %{ "status" => "1", "message" => "OK", @@ -625,6 +639,21 @@ defmodule BlockScoutWeb.Etherscan do } } + @token_balance_model %{ + name: "TokenBalance", + fields: %{ + balance: %{ + type: "integer", + definition: "The token account balance.", + example: ~s("135499") + }, + name: @token_name_type, + symbol: @token_symbol_type, + decimals: @token_decimal_type, + contractAddress: @address_hash_type + } + } + @block_reward_model %{ name: "BlockReward", fields: %{ @@ -1047,6 +1076,43 @@ defmodule BlockScoutWeb.Etherscan do ] } + @account_tokenlist_action %{ + name: "tokenlist", + description: "Get list of tokens owned by address.", + required_params: [ + %{ + key: "address", + placeholder: "addressHash", + type: "string", + description: "A 160-bit code used for identifying accounts." + } + ], + optional_params: [], + responses: [ + %{ + code: "200", + description: "successful operation", + example_value: Jason.encode!(@account_tokenlist_example_value), + model: %{ + name: "Result", + fields: %{ + status: @status_type, + message: @message_type, + result: %{ + type: "array", + array_type: @token_balance_model + } + } + } + }, + %{ + code: "200", + description: "error", + example_value: Jason.encode!(@account_tokenbalance_example_value_error) + } + ] + } + @account_getminedblocks_action %{ name: "getminedblocks", description: "Get list of blocks mined by address.", @@ -1537,6 +1603,7 @@ defmodule BlockScoutWeb.Etherscan do @account_txlistinternal_action, @account_tokentx_action, @account_tokenbalance_action, + @account_tokenlist_action, @account_getminedblocks_action ] } diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex index 53c1b08121..ab15424704 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex @@ -42,6 +42,11 @@ defmodule BlockScoutWeb.API.RPC.AddressView do RPCView.render("show.json", data: to_string(token_balance)) end + def render("token_list.json", %{token_list: token_list}) do + data = Enum.map(token_list, &prepare_token/1) + RPCView.render("show.json", data: data) + end + def render("getminedblocks.json", %{blocks: blocks}) do data = Enum.map(blocks, &prepare_block/1) RPCView.render("show.json", data: data) @@ -122,4 +127,14 @@ defmodule BlockScoutWeb.API.RPC.AddressView do "blockReward" => to_string(block.reward.value) } end + + defp prepare_token(token) do + %{ + "balance" => to_string(token.balance), + "contractAddress" => to_string(token.contract_address_hash), + "name" => token.name, + "decimals" => to_string(token.decimals), + "symbol" => token.symbol + } + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs index 41a84922a4..7b6f45cfba 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs @@ -2,6 +2,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do use BlockScoutWeb.ConnCase alias Explorer.Chain + alias Explorer.Repo alias Explorer.Chain.{Transaction, Wei} alias BlockScoutWeb.API.RPC.AddressController @@ -1540,6 +1541,131 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do end end + describe "tokenlist" do + test "without address param", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokenlist" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "address is required" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with an invalid address hash", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokenlist", + "address" => "badhash" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with an address that doesn't exist", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokenlist", + "address" => "0x9bf38d4764929064f2d4d3a56520a76ab3df415b" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "0" + assert response["message"] == "No tokens found" + end + + test "with an address without row in token_balances table", %{conn: conn} do + address = insert(:address) + + params = %{ + "module" => "account", + "action" => "tokenlist", + "address" => to_string(address.hash) + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "0" + assert response["message"] == "No tokens found" + end + + test "with address with existing balance in token_balances table", %{conn: conn} do + token_balance = :token_balance |> insert() |> Repo.preload(:token) + + params = %{ + "module" => "account", + "action" => "tokenlist", + "address" => to_string(token_balance.address_hash) + } + + expected_result = [ + %{ + "balance" => to_string(token_balance.value), + "contractAddress" => to_string(token_balance.token_contract_address_hash), + "name" => token_balance.token.name, + "decimals" => to_string(token_balance.token.decimals), + "symbol" => token_balance.token.symbol + } + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + end + + test "with address with multiple tokens", %{conn: conn} do + address = insert(:address) + other_address = insert(:address) + insert(:token_balance, address: address) + insert(:token_balance, address: address) + insert(:token_balance, address: other_address) + + params = %{ + "module" => "account", + "action" => "tokenlist", + "address" => to_string(address.hash) + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 2 + assert response["status"] == "1" + assert response["message"] == "OK" + end + end + describe "getminedblocks" do test "with missing address hash", %{conn: conn} do params = %{ diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index 8f23683c3d..3edf604319 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -183,6 +183,31 @@ defmodule Explorer.Etherscan do Repo.one(query) end + @doc """ + Gets a list of tokens owned by the given address hash. + + """ + @spec list_tokens(Hash.Address.t()) :: map() | [] + def list_tokens(%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash) do + query = + from( + tb in TokenBalance, + inner_join: t in assoc(tb, :token), + where: tb.address_hash == ^address_hash, + distinct: :token_contract_address_hash, + order_by: [desc: :block_number], + select: %{ + balance: tb.value, + contract_address_hash: tb.token_contract_address_hash, + name: t.name, + decimals: t.decimals, + symbol: t.symbol + } + ) + + Repo.all(query) + end + @transaction_fields ~w( block_hash block_number diff --git a/apps/explorer/test/explorer/etherscan_test.exs b/apps/explorer/test/explorer/etherscan_test.exs index ce7557b8eb..210e30580a 100644 --- a/apps/explorer/test/explorer/etherscan_test.exs +++ b/apps/explorer/test/explorer/etherscan_test.exs @@ -1103,4 +1103,86 @@ defmodule Explorer.EtherscanTest do assert found_token_balance.id == token_balance2.id end end + + describe "list_tokens/1" do + test "returns the tokens owned by an address hash" do + address = insert(:address) + + token_balance = + :token_balance + |> insert(address: address) + |> Repo.preload(:token) + + insert(:token_balance, address: build(:address)) + + token_list = Etherscan.list_tokens(address.hash) + + expected_tokens = [ + %{ + balance: token_balance.value, + contract_address_hash: token_balance.token_contract_address_hash, + name: token_balance.token.name, + decimals: token_balance.token.decimals, + symbol: token_balance.token.symbol + } + ] + + assert token_list == expected_tokens + end + + test "returns the latest known balance per token" do + # The latest balance is the one with the latest block number + address = insert(:address) + token = insert(:token) + + token_balance_details1 = %{ + address: address, + token_contract_address_hash: token.contract_address.hash, + block_number: 1 + } + + token_balance_details2 = %{ + address: address, + token_contract_address_hash: token.contract_address.hash, + block_number: 2 + } + + token_balance_details3 = %{ + address: address, + token_contract_address_hash: token.contract_address.hash, + block_number: 3 + } + + insert(:token_balance, token_balance_details1) + + token_balance = + :token_balance + |> insert(token_balance_details3) + |> Repo.preload(:token) + + insert(:token_balance, token_balance_details2) + + token_list = Etherscan.list_tokens(address.hash) + + expected_tokens = [ + %{ + balance: token_balance.value, + contract_address_hash: token_balance.token_contract_address_hash, + name: token_balance.token.name, + decimals: token_balance.token.decimals, + symbol: token_balance.token.symbol + } + ] + + assert token_list == expected_tokens + end + + test "returns an empty list when there are no token balances" do + address = insert(:address) + + insert(:token_balance, address: build(:address)) + + assert Etherscan.list_tokens(address.hash) == [] + end + end end