defmodule Web3.HTTP do @moduledoc """ JSONRPC over HTTP """ require Logger import Web3, only: [to_integer: 1] @doc """ Sends JSONRPC request encoded as `t:iodata/0` to `url` with `options` ## Examples iex> request = %{jsonrpc: "2.0", method: "eth_getBalance", params: ["0x1B93C60808449eF4B675caFAca8e7b40999f3fc5", "latest"], id: 1} iex> options = [url: "https://bsc-dataseed4.ninicoin.io/", http: Web3.HTTP.HTTPoison, http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :web3]]] iex> Web3.HTTP.json_rpc(request, options) {:ok, %{}} iex> request = [%{id: 1, jsonrpc: "2.0", method: "eth_getBalance", params: ["0x1B93C60808449eF4B675caFAca8e7b40999f3fc5", "latest"]}, %{id: 2, jsonrpc: "2.0", method: "eth_blockNumber", params: []}] iex> options = [url: "https://bsc-dataseed4.ninicoin.io/", http: Web3.HTTP.HTTPoison, http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :web3]]] iex> Web3.HTTP.json_rpc(request, options) {:ok, [%{id: 1, result: ""}, %{id: 2, result: ""}]} """ @callback json_rpc(url :: String.t(), json :: iodata(), options :: term()) :: {:ok, %{body: body :: String.t(), status_code: status_code :: pos_integer()}} | {:error, reason :: term} def json_rpc(%{method: _method} = request, options) when is_map(request) do json = encode_json(request) http = Keyword.fetch!(options, :http) url = Keyword.fetch!(options, :rpc_endpoint) http_options = Keyword.fetch!(options, :http_options) with {:ok, %{body: body, status_code: code}} <- http.json_rpc(url, json, http_options), {:ok, json} <- decode_json(request: [url: url, body: json], response: [status_code: code, body: body]) do handle_response(json, code) end end def json_rpc(batch_request, options) when is_list(batch_request) do chunked_json_rpc([batch_request], options, []) end defp chunked_json_rpc([], _options, decoded_response_bodies) when is_list(decoded_response_bodies) do list = decoded_response_bodies |> Enum.reverse() |> List.flatten() |> Enum.map(&standardize_response/1) {:ok, list} end # JSONRPC 2.0 standard says that an empty batch (`[]`) returns an empty response (`""`), but an empty response isn't # valid JSON, so instead act like it returns an empty list (`[]`) defp chunked_json_rpc([[] | tail], options, decoded_response_bodies) do chunked_json_rpc(tail, options, decoded_response_bodies) end defp chunked_json_rpc([[%{method: _method} | _] = batch | tail] = chunks, options, decoded_response_bodies) when is_list(tail) and is_list(decoded_response_bodies) do http = Keyword.fetch!(options, :http) url = Keyword.fetch!(options, :rpc_endpoint) http_options = Keyword.fetch!(options, :http_options) json = encode_json(batch) case http.json_rpc(url, json, http_options) do {:ok, %{status_code: status_code} = response} when status_code in [413, 504] -> rechunk_json_rpc(chunks, options, response, decoded_response_bodies) {:ok, %{body: body, status_code: status_code}} -> with {:ok, decoded_body} <- decode_json( request: [url: url, body: json], response: [status_code: status_code, body: body] ) do chunked_json_rpc(tail, options, [decoded_body | decoded_response_bodies]) end {:error, :timeout} -> rechunk_json_rpc(chunks, options, :timeout, decoded_response_bodies) {:error, _} = error -> error end end defp rechunk_json_rpc([batch | tail], options, response, decoded_response_bodies) do case length(batch) do # it can't be made any smaller 1 -> Logger.error(fn -> "413 Request Entity Too Large returned from single request batch. Cannot shrink batch further." end) {:error, response} batch_size -> split_size = div(batch_size, 2) {first_chunk, second_chunk} = Enum.split(batch, split_size) new_chunks = [first_chunk, second_chunk | tail] chunked_json_rpc(new_chunks, options, decoded_response_bodies) end end defp encode_json(data), do: Jason.encode_to_iodata!(data) defp decode_json(named_arguments) when is_list(named_arguments) do response = Keyword.fetch!(named_arguments, :response) response_body = Keyword.fetch!(response, :body) with {:error, _} <- Jason.decode(response_body, keys: :atoms) do case Keyword.fetch!(response, :status_code) do # CloudFlare protected server return HTML errors for 502, so the JSON decode will fail 502 -> request_url = named_arguments |> Keyword.fetch!(:request) |> Keyword.fetch!(:url) {:error, {:bad_gateway, request_url}} _ -> raise """ Failed to decode JSONRPC response: request: url: body: response: status code: body: """ end end end defp handle_response(resp, 200) do case resp do %{error: error} -> {:error, standardize_error(error)} %{result: result} -> {:ok, result} end end defp handle_response(resp, _status) do {:error, resp} end # restrict response to only those fields supported by the JSON-RPC 2.0 standard, which means that level of keys is # validated, so we can indicate that with switch to atom keys. def standardize_response(%{jsonrpc: "2.0" = jsonrpc, id: id} = unstandardized) do # Nethermind return string ids id = to_integer(id) standardized = %{jsonrpc: jsonrpc, id: id} case unstandardized do %{result: _, error: _} -> raise ArgumentError, "result and error keys are mutually exclusive in JSONRPC 2.0 response objects, but got #{inspect(unstandardized)}" %{result: result} -> Map.put(standardized, :result, result) %{error: error} -> Map.put(standardized, :error, standardize_error(error)) end end # restrict error to only those fields supported by the JSON-RPC 2.0 standard, which means that level of keys is # validated, so we can indicate that with switch to atom keys. def standardize_error(%{code: code, message: message} = unstandardized) when is_integer(code) and is_binary(message) do standardized = %{code: code, message: message} case Map.fetch(unstandardized, "data") do {:ok, data} -> Map.put(standardized, :data, data) :error -> standardized end end end