diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex index 7d04df023d..ac7fdcee61 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex @@ -13,7 +13,12 @@ defmodule EthereumJSONRPC.Transaction do alias EthereumJSONRPC @type elixir :: %{ - String.t() => EthereumJSONRPC.address() | EthereumJSONRPC.hash() | String.t() | non_neg_integer() | nil + String.t() => + EthereumJSONRPC.address() + | EthereumJSONRPC.hash() + | String.t() + | non_neg_integer() + | nil } @typedoc """ @@ -43,7 +48,11 @@ defmodule EthereumJSONRPC.Transaction do """ @type t :: %{ String.t() => - EthereumJSONRPC.address() | EthereumJSONRPC.hash() | EthereumJSONRPC.quantity() | String.t() | nil + EthereumJSONRPC.address() + | EthereumJSONRPC.hash() + | EthereumJSONRPC.quantity() + | String.t() + | nil } @type params :: %{ @@ -137,6 +146,35 @@ defmodule EthereumJSONRPC.Transaction do } end + # Ganache bug. it return `to: "0x0"` except of `to: null` + def elixir_to_params( + %{ + "to" => "0x0" + } = tr + ) do + elixir_to_params(%{tr | "to" => nil}) + end + + # Ganache bug. It don't send `r,s,v` transaction fields. + # Fix is in sources but not released yet + def elixir_to_params( + %{ + "blockHash" => block_hash, + "blockNumber" => block_number, + "from" => from_address_hash, + "gas" => gas, + "gasPrice" => gas_price, + "hash" => hash, + "input" => input, + "nonce" => nonce, + "to" => to_address_hash, + "transactionIndex" => index, + "value" => value + } = tr + ) do + elixir_to_params(%{tr | "r" => 0, "s" => 0, "v" => 0}) + end + @doc """ Extracts `t:EthereumJSONRPC.hash/0` from transaction `params` @@ -226,7 +264,8 @@ defmodule EthereumJSONRPC.Transaction do when key in ~w(blockHash condition creates from hash input jsonrpc publicKey raw to), do: {key, value} - defp entry_to_elixir({key, quantity}) when key in ~w(gas gasPrice nonce r s standardV v value) and quantity != nil do + defp entry_to_elixir({key, quantity}) + when key in ~w(gas gasPrice nonce r s standardV v value) and quantity != nil do {key, quantity_to_integer(quantity)} end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client_ganache.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client_ganache.ex new file mode 100644 index 0000000000..c549a2e5ee --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client_ganache.ex @@ -0,0 +1,345 @@ +defmodule EthereumJSONRPC.WebSocket.WebSocketClientGanache do + @moduledoc """ + `EthereumJSONRPC.WebSocket` that uses `websocket_client` + """ + + require Logger + + import EthereumJSONRPC, only: [request: 1] + + alias EthereumJSONRPC.{Subscription, Transport, WebSocket} + alias EthereumJSONRPC.WebSocket.Registration + + @behaviour :websocket_client + @behaviour WebSocket + + @enforce_keys ~w(url)a + defstruct request_id_to_registration: %{}, + subscription_id_to_subscription: %{}, + url: nil + + # Supervisor interface + + @impl WebSocket + def child_spec(arg) do + Supervisor.child_spec(%{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}}, []) + end + + @impl WebSocket + # only allow secure WSS + def start_link(["wss://" <> _ = url, gen_fsm_options]) when is_list(gen_fsm_options) do + fsm_name = + case Keyword.fetch(gen_fsm_options, :name) do + {:ok, name} when is_atom(name) -> {:local, name} + :error -> :undefined + end + + %URI{host: host} = URI.parse(url) + host_charlist = String.to_charlist(host) + + # `:depth`, `:verify`, and `:verify_fun`, are based on `:hackney_connect.ssl_opts_1/2` as we use `:hackney` through + # `:httpoison` and this keeps the SSL rules consistent between HTTP and WebSocket + :websocket_client.start_link( + fsm_name, + url, + __MODULE__, + url, + ssl_verify: :verify_peer, + socket_opts: [ + cacerts: :certifi.cacerts(), + depth: 99, + # SNI extension discloses host name in the clear, but allows for compatibility with Virtual Hosting for TLS + server_name_indication: host_charlist, + verify_fun: {&:ssl_verify_hostname.verify_fun/3, [check_hostname: host_charlist]} + ] + ) + end + + def start_link(["ws://" <> _ = url, gen_fsm_options]) when is_list(gen_fsm_options) do + fsm_name = + case Keyword.fetch(gen_fsm_options, :name) do + {:ok, name} when is_atom(name) -> {:local, name} + :error -> :undefined + end + + :websocket_client.start_link( + fsm_name, + url, + __MODULE__, + url, + [] + ) + end + + # Client interface + + @impl WebSocket + @spec json_rpc(WebSocket.web_socket(), Transport.request()) :: {:ok, Transport.result()} | {:error, reason :: term()} + def json_rpc(web_socket, request) do + GenServer.call(web_socket, {:json_rpc, request}) + end + + @impl WebSocket + @spec subscribe(WebSocket.web_socket(), Subscription.event(), Subscription.params()) :: + {:ok, Subscription.t()} | {:error, reason :: term()} + def subscribe(web_socket, event, params) when is_binary(event) and is_list(params) do + GenServer.call(web_socket, {:subscribe, event, params}) + end + + @impl WebSocket + @spec unsubscribe(WebSocket.web_socket(), Subscription.t()) :: :ok | {:error, :not_found} + def unsubscribe(web_socket, %Subscription{} = subscription) do + GenServer.call(web_socket, {:unsubscribe, subscription}) + end + + @impl :websocket_client + def init(url) do + {:reconnect, %__MODULE__{url: url}} + end + + @impl :websocket_client + def onconnect(_, %__MODULE__{} = state) do + {:ok, state} + end + + @impl :websocket_client + def ondisconnect(reason, %__MODULE__{} = state) do + {:close, reason, state} + end + + @impl :websocket_client + def websocket_handle({:text, text}, _request, %__MODULE__{} = state) do + case Jason.decode(text) do + {:ok, json} -> + handle_response(json, state) + + {:error, _} = error -> + broadcast(error, state) + {:ok, state} + end + end + + @impl :websocket_client + def websocket_info({:"$gen_call", from, request}, _, %__MODULE__{} = state) do + handle_call(request, from, state) + end + + @impl :websocket_client + def websocket_terminate(close, _request, %__MODULE__{} = state) do + broadcast(close, state) + end + + defp broadcast(message, %__MODULE__{subscription_id_to_subscription: id_to_subscription}) do + id_to_subscription + |> Map.values() + |> Subscription.broadcast(message) + end + + defp handle_call(message, from, %__MODULE__{} = state) do + {updated_state, unique_request} = register(message, from, state) + + {:reply, {:text, Jason.encode!(unique_request)}, updated_state} + end + + defp handle_response( + %{"method" => "eth_subscription", "params" => %{"result" => result, "subscription" => subscription_id}}, + %__MODULE__{subscription_id_to_subscription: subscription_id_to_subscription} = state + ) do + case subscription_id_to_subscription do + %{^subscription_id => subscription} -> + Subscription.publish(subscription, {:ok, result}) + + _ -> + Logger.error(fn -> + [ + "Unexpected `eth_subscription` subscription ID (", + inspect(subscription_id), + ") result (", + inspect(result), + "). Subscription ID not in known subscription IDs (", + subscription_id_to_subscription + |> Map.values() + |> Enum.map(&inspect/1), + ")." + ] + end) + end + + {:ok, state} + end + + defp handle_response( + %{"id" => id} = response, + %__MODULE__{request_id_to_registration: request_id_to_registration} = state + ) do + {registration, new_request_id_to_registration} = Map.pop(request_id_to_registration, id) + respond_to_registration(registration, new_request_id_to_registration, response, state) + end + + defp handle_response(response, %__MODULE__{} = state) do + Logger.error(fn -> + [ + "Unexpected JSON response from web socket\n", + "\n", + " Response:\n", + " ", + inspect(response) + ] + end) + + {:ok, state} + end + + defp register( + {:json_rpc, original_request}, + from, + %__MODULE__{request_id_to_registration: request_id_to_registration} = state + ) do + unique_id = unique_request_id(state) + + {%__MODULE__{ + state + | request_id_to_registration: + Map.put(request_id_to_registration, unique_id, %Registration{ + from: from, + type: :json_rpc + }) + }, %{original_request | id: unique_id}} + end + + defp register( + {:subscribe, event, params}, + from, + %__MODULE__{request_id_to_registration: request_id_to_registration} = state + ) + when is_binary(event) and is_list(params) do + unique_id = unique_request_id(state) + + { + %__MODULE__{ + state + | request_id_to_registration: + Map.put(request_id_to_registration, unique_id, %Registration{from: from, type: :subscribe}) + }, + request(%{id: unique_id, method: "eth_subscribe", params: [event | params]}) + } + end + + defp register( + {:unsubscribe, %Subscription{id: subscription_id}}, + from, + %__MODULE__{request_id_to_registration: request_id_to_registration} = state + ) do + unique_id = unique_request_id(state) + + { + %__MODULE__{ + state + | request_id_to_registration: + Map.put(request_id_to_registration, unique_id, %Registration{ + from: from, + type: :unsubscribe, + subscription_id: subscription_id + }) + }, + request(%{id: unique_id, method: "eth_unsubscribe", params: [subscription_id]}) + } + end + + defp respond_to_registration( + %Registration{type: :json_rpc, from: from}, + new_request_id_to_registration, + response, + %__MODULE__{} = state + ) do + reply = + case response do + %{"result" => result} -> {:ok, result} + %{"error" => error} -> {:error, error} + end + + GenServer.reply(from, reply) + + {:ok, %__MODULE__{state | request_id_to_registration: new_request_id_to_registration}} + end + + defp respond_to_registration( + %Registration{type: :subscribe, from: {subscriber_pid, _} = from}, + new_request_id_to_registration, + %{"result" => subscription_id}, + %__MODULE__{url: url} = state + ) do + subscription = %Subscription{ + id: subscription_id, + subscriber_pid: subscriber_pid, + transport: EthereumJSONRPC.WebSocket, + transport_options: [web_socket: __MODULE__, web_socket_options: %{web_socket: self()}, url: url] + } + + GenServer.reply(from, {:ok, subscription}) + + new_state = + state + |> put_in([Access.key!(:request_id_to_registration)], new_request_id_to_registration) + |> put_in([Access.key!(:subscription_id_to_subscription), subscription_id], subscription) + + {:ok, new_state} + end + + defp respond_to_registration( + %Registration{type: :subscribe, from: from}, + new_request_id_to_registration, + %{"error" => error}, + %__MODULE__{} = state + ) do + GenServer.reply(from, {:error, error}) + + {:ok, %__MODULE__{state | request_id_to_registration: new_request_id_to_registration}} + end + + defp respond_to_registration( + %Registration{type: :unsubscribe, from: from, subscription_id: subscription_id}, + new_request_id_to_registration, + response, + %__MODULE__{} = state + ) do + reply = + case response do + %{"result" => true} -> :ok + %{"result" => false} -> {:error, :not_found} + %{"error" => %{"message" => "subscription not found"}} -> {:error, :not_found} + %{"error" => error} -> {:error, error} + end + + GenServer.reply(from, reply) + + new_state = + state + |> put_in([Access.key!(:request_id_to_registration)], new_request_id_to_registration) + |> update_in([Access.key!(:subscription_id_to_subscription)], &Map.delete(&1, subscription_id)) + + {:ok, new_state} + end + + defp respond_to_registration(nil, _, response, %__MODULE__{} = state) do + Logger.error(fn -> ["Got response for unregistered request ID: ", inspect(response)] end) + + {:ok, state} + end + + defp unique_request_id(%__MODULE__{request_id_to_registration: request_id_to_registration} = state) do + # Ganache couldn't work with int64. + # https://github.com/trufflesuite/ganache-core/issues/190 + <> = :crypto.strong_rand_bytes(4) + + case request_id_to_registration do + # collision + %{^unique_request_id => _} -> + unique_request_id(state) + + _ -> + unique_request_id + end + end + end + \ No newline at end of file diff --git a/apps/explorer/config/dev/ganache.exs b/apps/explorer/config/dev/ganache.exs new file mode 100644 index 0000000000..260f4ea663 --- /dev/null +++ b/apps/explorer/config/dev/ganache.exs @@ -0,0 +1,20 @@ +use Mix.Config + +config :explorer, + json_rpc_named_arguments: [ + transport: EthereumJSONRPC.HTTP, + transport_options: [ + http: EthereumJSONRPC.HTTP.HTTPoison, + url: System.get_env("ETHEREUM_JSONRPC_HTTP_URL") || "http://localhost:7545", + http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]] + ], + variant: EthereumJSONRPC.Geth + ], + subscribe_named_arguments: [ + transport: EthereumJSONRPC.WebSocket, + transport_options: [ + web_socket: EthereumJSONRPC.WebSocket.WebSocketClientGanache, + url: System.get_env("ETHEREUM_JSONRPC_WEB_SOCKET_URL") || "ws://localhost:7545" + ], + variant: EthereumJSONRPC.Geth + ] diff --git a/apps/indexer/config/dev/ganache.exs b/apps/indexer/config/dev/ganache.exs new file mode 100644 index 0000000000..b8c06b7e0a --- /dev/null +++ b/apps/indexer/config/dev/ganache.exs @@ -0,0 +1,20 @@ +use Mix.Config + +config :indexer, + block_interval: 5_000, + json_rpc_named_arguments: [ + transport: EthereumJSONRPC.HTTP, + transport_options: [ + http: EthereumJSONRPC.HTTP.HTTPoison, + url: System.get_env("ETHEREUM_JSONRPC_HTTP_URL") || "http://localhost:7545", + http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]] + ], + variant: EthereumJSONRPC.Geth + ], + subscribe_named_arguments: [ + transport: EthereumJSONRPC.WebSocket, + transport_options: [ + web_socket: EthereumJSONRPC.WebSocket.WebSocketClientGanache, + url: System.get_env("ETHEREUM_JSONRPC_WEB_SOCKET_URL") || "ws://localhost:7545" + ] + ]