diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f2731a6d..487bb5c558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - [#2108](https://github.com/poanetwork/blockscout/pull/2108) - fix uncle fetching without full transactions - [#2123](https://github.com/poanetwork/blockscout/pull/2123) - fix coins percentage view - [#2119](https://github.com/poanetwork/blockscout/pull/2119) - fix map logging +- [#2130](https://github.com/poanetwork/blockscout/pull/2130) - fix navigation ### Chore - [#2127](https://github.com/poanetwork/blockscout/pull/2127) - use previouse chromedriver version @@ -49,6 +50,7 @@ - [#2037](https://github.com/poanetwork/blockscout/pull/2037) - add address logs search functionality - [#2012](https://github.com/poanetwork/blockscout/pull/2012) - make all pages pagination async - [#2064](https://github.com/poanetwork/blockscout/pull/2064) - feat: add fields to tx apis, small cleanups +- [#2100](https://github.com/poanetwork/blockscout/pull/2100) - feat: eth_get_balance rpc endpoint ### Fixes - [#2099](https://github.com/poanetwork/blockscout/pull/2099) - logs search input width 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 be10089bad..3150d3bd60 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 @@ -20,6 +20,35 @@ defmodule BlockScoutWeb.API.RPC.AddressController do |> render(:listaccounts, %{accounts: accounts}) end + def eth_get_balance(conn, params) do + with {:address_param, {:ok, address_param}} <- fetch_address(params), + {:block_param, {:ok, block}} <- {:block_param, fetch_block_param(params)}, + {:format, {:ok, address_hash}} <- to_address_hash(address_param), + {:balance, {:ok, balance}} <- {:balance, Chain.get_balance_as_of_block(address_hash, block)} do + render(conn, :eth_get_balance, %{balance: Wei.hex_format(balance)}) + else + {:address_param, :error} -> + conn + |> put_status(400) + |> render(:eth_get_balance_error, %{message: "Query parameter 'address' is required"}) + + {:format, :error} -> + conn + |> put_status(400) + |> render(:eth_get_balance_error, %{error: "Invalid address hash"}) + + {:block_param, :error} -> + conn + |> put_status(400) + |> render(:eth_get_balance_error, %{error: "Invalid block"}) + + {:balance, {:error, :not_found}} -> + conn + |> put_status(404) + |> render(:eth_get_balance_error, %{error: "Balance not found"}) + end + end + def balance(conn, params, template \\ :balance) do with {:address_param, {:ok, address_param}} <- fetch_address(params), {:format, {:ok, address_hashes}} <- to_address_hashes(address_param) do @@ -217,6 +246,20 @@ defmodule BlockScoutWeb.API.RPC.AddressController do {:required_params, result} end + defp fetch_block_param(%{"block" => "latest"}), do: {:ok, :latest} + defp fetch_block_param(%{"block" => "earliest"}), do: {:ok, :earliest} + defp fetch_block_param(%{"block" => "pending"}), do: {:ok, :pending} + + defp fetch_block_param(%{"block" => string_integer}) when is_bitstring(string_integer) do + case Integer.parse(string_integer) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + defp fetch_block_param(%{"block" => _block}), do: :error + defp fetch_block_param(_), do: {:ok, :latest} + defp to_valid_format(params, :tokenbalance) do result = with {:ok, contract_address_hash} <- to_address_hash(params, "contractaddress"), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/eth_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/eth_controller.ex new file mode 100644 index 0000000000..693772ed8c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/eth_controller.ex @@ -0,0 +1,118 @@ +defmodule BlockScoutWeb.API.RPC.EthController do + use BlockScoutWeb, :controller + + alias Explorer.Chain + alias Explorer.Chain.Wei + + def eth_request(%{body_params: %{"_json" => requests}} = conn, _) when is_list(requests) do + responses = responses(requests) + + conn + |> put_status(200) + |> render("responses.json", %{responses: responses}) + end + + def eth_request(%{body_params: %{"_json" => request}} = conn, _) do + [response] = responses([request]) + + conn + |> put_status(200) + |> render("response.json", %{response: response}) + end + + def eth_request(conn, request) do + # In the case that the JSON body is sent up w/o a json content type, + # Phoenix encodes it as a single key value pair, with the value being + # nil and the body being the key (as in a CURL request w/ no content type header) + decoded_request = + with [{single_key, nil}] <- Map.to_list(request), + {:ok, decoded} <- Jason.decode(single_key) do + decoded + else + _ -> request + end + + [response] = responses([decoded_request]) + + conn + |> put_status(200) + |> render("response.json", %{response: response}) + end + + defp responses(requests) do + Enum.map(requests, fn request -> + with {:id, {:ok, id}} <- {:id, Map.fetch(request, "id")}, + {:request, {:ok, result}} <- {:request, do_eth_request(request)} do + format_success(result, id) + else + {:id, :error} -> format_error("id is a required field", 0) + {:request, {:error, message}} -> format_error(message, Map.get(request, "id")) + end + end) + end + + defp format_success(result, id) do + %{result: result, id: id} + end + + defp format_error(message, id) do + %{error: message, id: id} + end + + defp do_eth_request(%{"jsonrpc" => rpc_version}) when rpc_version != "2.0" do + {:error, "invalid rpc version"} + end + + defp do_eth_request(%{"jsonrpc" => "2.0", "method" => method, "params" => params}) + when is_list(params) do + with {:ok, action} <- get_action(method), + true <- :erlang.function_exported(__MODULE__, action, Enum.count(params)) do + apply(__MODULE__, action, params) + else + _ -> + {:error, "Action not found."} + end + end + + defp do_eth_request(%{"params" => _params, "method" => _}) do + {:error, "Invalid params. Params must be a list."} + end + + defp do_eth_request(_) do + {:error, "Method, params, and jsonrpc, are all required parameters."} + end + + def eth_get_balance(address_param, block_param \\ nil) do + with {:address, {:ok, address}} <- {:address, Chain.string_to_address_hash(address_param)}, + {:block, {:ok, block}} <- {:block, block_param(block_param)}, + {:balance, {:ok, balance}} <- {:balance, Chain.get_balance_as_of_block(address, block)} do + {:ok, Wei.hex_format(balance)} + else + {:address, :error} -> + {:error, "Query parameter 'address' is invalid"} + + {:block, :error} -> + {:error, "Query parameter 'block' is invalid"} + + {:balance, {:error, :not_found}} -> + {:error, "Balance not found"} + end + end + + defp get_action("eth_getBalance"), do: {:ok, :eth_get_balance} + defp get_action(_), do: :error + + defp block_param("latest"), do: {:ok, :latest} + defp block_param("earliest"), do: {:ok, :earliest} + defp block_param("pending"), do: {:ok, :pending} + + defp block_param(string_integer) when is_bitstring(string_integer) do + case Integer.parse(string_integer) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + defp block_param(nil), do: {:ok, :latest} + defp block_param(_), do: :error +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 74a9fa761c..2191a86fa2 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -100,6 +100,12 @@ defmodule BlockScoutWeb.Etherscan do "result" => [] } + @account_eth_get_balance_example_value %{ + "jsonrpc" => "2.0", + "result" => "0x0234c8a3397aab58", + "id" => 1 + } + @account_tokentx_example_value %{ "status" => "1", "message" => "OK", @@ -1028,6 +1034,49 @@ defmodule BlockScoutWeb.Etherscan do } } + @account_eth_get_balance_action %{ + name: "eth_get_balance", + description: + "Mimics Ethereum JSON RPC's eth_getBalance. Returns the balance as of the provided block (defaults to latest)", + required_params: [ + %{ + key: "address", + placeholder: "addressHash", + type: "string", + description: "The address of the account." + } + ], + optional_params: [ + %{ + key: "block", + placeholder: "block", + type: "string", + description: """ + Either the block number as a string, or one of latest, earliest or pending + + latest will be the latest balance in a *consensus* block. + earliest will be the first recorded balance for the address. + pending will be the latest balance in consensus *or* nonconcensus blocks. + """ + } + ], + responses: [ + %{ + code: "200", + description: "successful operation", + example_value: Jason.encode!(@account_eth_get_balance_example_value), + model: %{ + name: "Result", + fields: %{ + jsonrpc: @jsonrpc_version_type, + id: @id_type, + result: @hex_number_type + } + } + } + ] + } + @account_balance_action %{ name: "balance", description: """ @@ -2203,6 +2252,7 @@ defmodule BlockScoutWeb.Etherscan do @account_module %{ name: "account", actions: [ + @account_eth_get_balance_action, @account_balance_action, @account_balancemulti_action, @account_txlist_action, 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 48a5c8f8da..786c77cdff 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -32,6 +32,8 @@ defmodule BlockScoutWeb.Router do alias BlockScoutWeb.API.RPC + post("/eth_rpc", EthController, :eth_request) + forward("/", RPCTranslator, %{ "block" => RPC.BlockController, "account" => RPC.AddressController, diff --git a/apps/block_scout_web/lib/block_scout_web/templates/common_components/_pagination_container.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/common_components/_pagination_container.html.eex index 2f9f470eed..f9992c6d86 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/common_components/_pagination_container.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/common_components/_pagination_container.html.eex @@ -1,4 +1,4 @@ -
disabled<% end %>> +
<%= if false do %>
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 7aaf26dba0..248dc942c7 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 @@ -1,7 +1,7 @@ defmodule BlockScoutWeb.API.RPC.AddressView do use BlockScoutWeb, :view - alias BlockScoutWeb.API.RPC.RPCView + alias BlockScoutWeb.API.RPC.{EthRPCView, RPCView} def render("listaccounts.json", %{accounts: accounts}) do accounts = Enum.map(accounts, &prepare_account/1) @@ -51,6 +51,10 @@ defmodule BlockScoutWeb.API.RPC.AddressView do RPCView.render("show.json", data: data) end + def render("eth_get_balance_error.json", %{error: message}) do + EthRPCView.render("error.json", %{error: message, id: 0}) + end + def render("error.json", assigns) do RPCView.render("error.json", assigns) end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_rpc_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_rpc_view.ex index 39eb5ae9d1..5dda92d3d5 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_rpc_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_rpc_view.ex @@ -17,16 +17,48 @@ defmodule BlockScoutWeb.API.RPC.EthRPCView do } end + def render("response.json", %{response: %{error: error, id: id}}) do + %__MODULE__{ + error: error, + id: id + } + end + + def render("response.json", %{response: %{result: result, id: id}}) do + %__MODULE__{ + result: result, + id: id + } + end + + def render("responses.json", %{responses: responses}) do + Enum.map(responses, fn + %{error: error, id: id} -> + %__MODULE__{ + error: error, + id: id + } + + %{result: result, id: id} -> + %__MODULE__{ + result: result, + id: id + } + end) + end + defimpl Poison.Encoder, for: BlockScoutWeb.API.RPC.EthRPCView do def encode(%BlockScoutWeb.API.RPC.EthRPCView{result: result, id: id, error: error}, _options) when is_nil(error) do + result = Poison.encode!(result) + """ - {"jsonrpc":"2.0","result":"#{result}","id":#{id}} + {"jsonrpc":"2.0","result":#{result},"id":#{id}} """ end def encode(%BlockScoutWeb.API.RPC.EthRPCView{id: id, error: error}, _options) do """ - {"jsonrpc":"2.0","error": #{error},"id": #{id}} + {"jsonrpc":"2.0","error": "#{error}","id": #{id}} """ end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_view.ex new file mode 100644 index 0000000000..739f3ac8a7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/eth_view.ex @@ -0,0 +1,13 @@ +defmodule BlockScoutWeb.API.RPC.EthView do + use BlockScoutWeb, :view + + alias BlockScoutWeb.API.RPC.EthRPCView + + def render("responses.json", %{responses: responses}) do + EthRPCView.render("responses.json", %{responses: responses}) + end + + def render("response.json", %{response: response}) do + EthRPCView.render("response.json", %{response: response}) + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs new file mode 100644 index 0000000000..b26becee2f --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs @@ -0,0 +1,199 @@ +defmodule BlockScoutWeb.API.RPC.EthControllerTest do + use BlockScoutWeb.ConnCase, async: false + + alias Explorer.Counters.{AddressesWithBalanceCounter, AverageBlockTime} + alias Indexer.Fetcher.CoinBalanceOnDemand + + setup do + mocked_json_rpc_named_arguments = [ + transport: EthereumJSONRPC.Mox, + transport_options: [] + ] + + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + start_supervised!(AverageBlockTime) + start_supervised!({CoinBalanceOnDemand, [mocked_json_rpc_named_arguments, [name: CoinBalanceOnDemand]]}) + start_supervised!(AddressesWithBalanceCounter) + + Application.put_env(:explorer, AverageBlockTime, enabled: true) + + on_exit(fn -> + Application.put_env(:explorer, AverageBlockTime, enabled: false) + end) + + :ok + end + + defp params(api_params, params), do: Map.put(api_params, "params", params) + + describe "eth_get_balance" do + setup do + %{ + api_params: %{ + "method" => "eth_getBalance", + "jsonrpc" => "2.0", + "id" => 0 + } + } + end + + test "with an invalid address", %{conn: conn, api_params: api_params} do + assert response = + conn + |> post("/api/eth_rpc", params(api_params, ["badHash"])) + |> json_response(200) + + assert %{"error" => "Query parameter 'address' is invalid"} = response + end + + test "with a valid address that has no balance", %{conn: conn, api_params: api_params} do + address = insert(:address) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash)])) + |> json_response(200) + + assert %{"error" => "Balance not found"} = response + end + + test "with a valid address that has a balance", %{conn: conn, api_params: api_params} do + block = insert(:block) + address = insert(:address) + + insert(:fetched_balance, block_number: block.number, address_hash: address.hash, value: 1) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash)])) + |> json_response(200) + + assert %{"result" => "0x1"} = response + end + + test "with a valid address that has no earliest balance", %{conn: conn, api_params: api_params} do + block = insert(:block, number: 1) + address = insert(:address) + + insert(:fetched_balance, block_number: block.number, address_hash: address.hash, value: 1) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "earliest"])) + |> json_response(200) + + assert response["error"] == "Balance not found" + end + + test "with a valid address that has an earliest balance", %{conn: conn, api_params: api_params} do + block = insert(:block, number: 0) + address = insert(:address) + + insert(:fetched_balance, block_number: block.number, address_hash: address.hash, value: 1) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "earliest"])) + |> json_response(200) + + assert response["result"] == "0x1" + end + + test "with a valid address and no pending balance", %{conn: conn, api_params: api_params} do + block = insert(:block, number: 1, consensus: true) + address = insert(:address) + + insert(:fetched_balance, block_number: block.number, address_hash: address.hash, value: 1) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "pending"])) + |> json_response(200) + + assert response["error"] == "Balance not found" + end + + test "with a valid address and a pending balance", %{conn: conn, api_params: api_params} do + block = insert(:block, number: 1, consensus: false) + address = insert(:address) + + insert(:fetched_balance, block_number: block.number, address_hash: address.hash, value: 1) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "pending"])) + |> json_response(200) + + assert response["result"] == "0x1" + end + + test "with a valid address and a pending balance after a consensus block", %{conn: conn, api_params: api_params} do + insert(:block, number: 1, consensus: true) + block = insert(:block, number: 2, consensus: false) + address = insert(:address) + + insert(:fetched_balance, block_number: block.number, address_hash: address.hash, value: 1) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "pending"])) + |> json_response(200) + + assert response["result"] == "0x1" + end + + test "with a block provided", %{conn: conn, api_params: api_params} do + address = insert(:address) + + insert(:fetched_balance, block_number: 1, address_hash: address.hash, value: 1) + insert(:fetched_balance, block_number: 2, address_hash: address.hash, value: 2) + insert(:fetched_balance, block_number: 3, address_hash: address.hash, value: 3) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "2"])) + |> json_response(200) + + assert response["result"] == "0x2" + end + + test "with a block provided and no balance", %{conn: conn, api_params: api_params} do + address = insert(:address) + + insert(:fetched_balance, block_number: 3, address_hash: address.hash, value: 3) + + assert response = + conn + |> post("/api/eth_rpc", params(api_params, [to_string(address.hash), "2"])) + |> json_response(200) + + assert response["error"] == "Balance not found" + end + + test "with a batch of requests", %{conn: conn} do + address = insert(:address) + + insert(:fetched_balance, block_number: 1, address_hash: address.hash, value: 1) + insert(:fetched_balance, block_number: 2, address_hash: address.hash, value: 2) + insert(:fetched_balance, block_number: 3, address_hash: address.hash, value: 3) + + params = [ + %{"id" => 0, "params" => [to_string(address.hash), "1"], "jsonrpc" => "2.0", "method" => "eth_getBalance"}, + %{"id" => 1, "params" => [to_string(address.hash), "2"], "jsonrpc" => "2.0", "method" => "eth_getBalance"}, + %{"id" => 2, "params" => [to_string(address.hash), "3"], "jsonrpc" => "2.0", "method" => "eth_getBalance"} + ] + + assert response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/eth_rpc", Jason.encode!(params)) + |> json_response(200) + + assert [ + %{"id" => 0, "result" => "0x1"}, + %{"id" => 1, "result" => "0x2"}, + %{"id" => 2, "result" => "0x3"} + ] = response + end + end +end diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 4edc94887a..d2fe1d19d5 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -824,6 +824,86 @@ defmodule Explorer.Chain do Repo.all(query) end + @doc """ + Returns the balance of the given address and block combination. + + Returns `{:error, :not_found}` if there is no address by that hash present. + Returns `{:error, :no_balance}` if there is no balance for that address at that block. + """ + @spec get_balance_as_of_block(Hash.Address.t(), integer | :earliest | :latest | :pending) :: + {:ok, Wei.t()} | {:error, :no_balance} | {:error, :not_found} + def get_balance_as_of_block(address, block) when is_integer(block) do + coin_balance_query = + from(coin_balance in CoinBalance, + where: coin_balance.address_hash == ^address, + where: not is_nil(coin_balance.value), + where: coin_balance.block_number <= ^block, + order_by: [desc: coin_balance.block_number], + limit: 1, + select: coin_balance.value + ) + + case Repo.one(coin_balance_query) do + nil -> {:error, :not_found} + coin_balance -> {:ok, coin_balance} + end + end + + def get_balance_as_of_block(address, :latest) do + case max_consensus_block_number() do + {:ok, latest_block_number} -> + get_balance_as_of_block(address, latest_block_number) + + {:error, :not_found} -> + {:error, :not_found} + end + end + + def get_balance_as_of_block(address, :earliest) do + query = + from(coin_balance in CoinBalance, + where: coin_balance.address_hash == ^address, + where: not is_nil(coin_balance.value), + where: coin_balance.block_number == 0, + limit: 1, + select: coin_balance.value + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + coin_balance -> {:ok, coin_balance} + end + end + + def get_balance_as_of_block(address, :pending) do + query = + case max_consensus_block_number() do + {:ok, latest_block_number} -> + from(coin_balance in CoinBalance, + where: coin_balance.address_hash == ^address, + where: not is_nil(coin_balance.value), + where: coin_balance.block_number > ^latest_block_number, + order_by: [desc: coin_balance.block_number], + limit: 1, + select: coin_balance.value + ) + + {:error, :not_found} -> + from(coin_balance in CoinBalance, + where: coin_balance.address_hash == ^address, + where: not is_nil(coin_balance.value), + order_by: [desc: coin_balance.block_number], + limit: 1, + select: coin_balance.value + ) + end + + case Repo.one(query) do + nil -> {:error, :not_found} + coin_balance -> {:ok, coin_balance} + end + end + @spec list_ordered_addresses(non_neg_integer(), non_neg_integer()) :: [Address.t()] def list_ordered_addresses(offset, limit) do query = diff --git a/apps/explorer/lib/explorer/chain/wei.ex b/apps/explorer/lib/explorer/chain/wei.ex index 4b12d3deb0..c1c434aa44 100644 --- a/apps/explorer/lib/explorer/chain/wei.ex +++ b/apps/explorer/lib/explorer/chain/wei.ex @@ -113,6 +113,17 @@ defmodule Explorer.Chain.Wei do @wei_per_ether Decimal.new(1_000_000_000_000_000_000) @wei_per_gwei Decimal.new(1_000_000_000) + @spec hex_format(Wei.t()) :: String.t() + def hex_format(%Wei{value: decimal}) do + hex = + decimal + |> Decimal.to_integer() + |> Integer.to_string(16) + |> String.downcase() + + "0x" <> hex + end + @doc """ Sums two Wei values.