diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b226a1f23..c3ebb44d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Current ### Features +- [#3584](https://github.com/poanetwork/blockscout/pull/3584) - Token holders API endpoint - [#3564](https://github.com/poanetwork/blockscout/pull/3564) - Staking welcome message ### Fixes diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex index ee965fd9b9..e24caee0d1 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex @@ -1,7 +1,8 @@ defmodule BlockScoutWeb.API.RPC.TokenController do use BlockScoutWeb, :controller - alias Explorer.Chain + alias BlockScoutWeb.API.RPC.Helpers + alias Explorer.{Chain, PagingOptions} def gettoken(conn, params) do with {:contractaddress_param, {:ok, contractaddress_param}} <- fetch_contractaddress(params), @@ -20,6 +21,34 @@ defmodule BlockScoutWeb.API.RPC.TokenController do end end + def gettokenholders(conn, params) do + with pagination_options <- Helpers.put_pagination_options(%{}, params), + {:contractaddress_param, {:ok, contractaddress_param}} <- fetch_contractaddress(params), + {:format, {:ok, address_hash}} <- to_address_hash(contractaddress_param) do + options_with_defaults = + pagination_options + |> Map.put_new(:page_number, 0) + |> Map.put_new(:page_size, 10) + + options = [ + paging_options: %PagingOptions{ + key: nil, + page_number: options_with_defaults.page_number, + page_size: options_with_defaults.page_size + } + ] + + token_holders = Chain.fetch_token_holders_from_token_hash(address_hash, options) + render(conn, "gettokenholders.json", %{token_holders: token_holders}) + else + {:contractaddress_param, :error} -> + render(conn, :error, error: "Query parameter contract address is required") + + {:format, :error} -> + render(conn, :error, error: "Invalid contract address hash") + end + end + defp fetch_contractaddress(params) do {:contractaddress_param, Map.fetch(params, "contractaddress")} 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 2b2d28b85d..f761a8e766 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -276,6 +276,23 @@ defmodule BlockScoutWeb.Etherscan do "result" => nil } + @token_gettokenholders_example_value %{ + "status" => "1", + "message" => "OK", + "result" => [ + %{ + "address" => "0x0000000000000000000000000000000000000000", + "value" => "965208500001258757122850" + } + ] + } + + @token_gettokenholders_example_value_error %{ + "status" => "0", + "message" => "Invalid contract address format", + "result" => nil + } + @stats_tokensupply_example_value %{ "status" => "1", "message" => "OK", @@ -664,6 +681,18 @@ defmodule BlockScoutWeb.Etherscan do } } + @token_holder_details %{ + name: "Token holder Detail", + fields: %{ + address: @address_hash_type, + value: %{ + type: "value", + definition: "A nonnegative number used to identify the balance of the target token.", + example: ~s("1000000000000000000") + } + } + } + @address_balance %{ name: "AddressBalance", fields: %{ @@ -1825,6 +1854,56 @@ defmodule BlockScoutWeb.Etherscan do ] } + @token_gettokenholders_action %{ + name: "getTokenHolders", + description: "Get token holders by contract address.", + required_params: [ + %{ + key: "contractaddress", + placeholder: "contractAddressHash", + type: "string", + description: "A 160-bit code used for identifying contracts." + } + ], + optional_params: [ + %{ + key: "page", + type: "integer", + description: + "A nonnegative integer that represents the page number to be used for pagination. 'offset' must be provided in conjunction." + }, + %{ + key: "offset", + type: "integer", + description: + "A nonnegative integer that represents the maximum number of records to return when paginating. 'page' must be provided in conjunction." + } + ], + responses: [ + %{ + code: "200", + description: "successful operation", + example_value: Jason.encode!(@token_gettokenholders_example_value), + model: %{ + name: "Result", + fields: %{ + status: @status_type, + message: @message_type, + result: %{ + type: "array", + array_type: @token_holder_details + } + } + } + }, + %{ + code: "200", + description: "error", + example_value: Jason.encode!(@token_gettokenholders_example_value_error) + } + ] + } + @stats_tokensupply_action %{ name: "tokensupply", description: @@ -2446,7 +2525,10 @@ defmodule BlockScoutWeb.Etherscan do @token_module %{ name: "token", - actions: [@token_gettoken_action] + actions: [ + @token_gettoken_action, + @token_gettokenholders_action + ] } @stats_module %{ diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex index a29b0e7796..9ccab7c9d1 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex @@ -7,6 +7,11 @@ defmodule BlockScoutWeb.API.RPC.TokenView do RPCView.render("show.json", data: prepare_token(token)) end + def render("gettokenholders.json", %{token_holders: token_holders}) do + data = Enum.map(token_holders, &prepare_token_holder/1) + RPCView.render("show.json", data: data) + end + def render("error.json", assigns) do RPCView.render("error.json", assigns) end @@ -22,4 +27,11 @@ defmodule BlockScoutWeb.API.RPC.TokenView do "cataloged" => token.cataloged } end + + defp prepare_token_holder(token_holder) do + %{ + "address" => to_string(token_holder.address_hash), + "value" => token_holder.value + } + end end diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 89e0e5d89d..4bcd063030 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -4601,7 +4601,7 @@ defmodule Explorer.Chain do end @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 + def fetch_token_holders_from_token_hash(contract_address_hash, options \\ []) do contract_address_hash |> CurrentTokenBalance.token_holders_ordered_by_value(options) |> Repo.all()