Land #335: Geth non-incompatibility
commit
fadba343e0
@ -1,4 +0,0 @@ |
||||
# Used by "mix format" |
||||
[ |
||||
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] |
||||
] |
@ -0,0 +1,5 @@ |
||||
use Mix.Config |
||||
|
||||
config :ethereum_jsonrpc, |
||||
url: "https://mainnet.infura.io/8lTvJTKmHPCHazkneJsY", |
||||
variant: EthereumJSONRPC.Geth |
@ -0,0 +1,9 @@ |
||||
use Mix.Config |
||||
|
||||
config :ethereum_jsonrpc, |
||||
url: "https://sokol.poa.network", |
||||
method_to_url: [ |
||||
eth_getBalance: "https://sokol-trace.poa.network", |
||||
trace_replayTransaction: "https://sokol-trace.poa.network" |
||||
], |
||||
variant: EthereumJSONRPC.Parity |
@ -0,0 +1,59 @@ |
||||
defmodule EthereumJSONRPC.DecodeError do |
||||
@moduledoc """ |
||||
An error has occurred decoding the response to an `EthereumJSONRPC.json_rpc` request. |
||||
""" |
||||
|
||||
@enforce_keys [:request, :response] |
||||
defexception [:request, :response] |
||||
|
||||
defmodule Request do |
||||
@moduledoc """ |
||||
Ethereum JSONRPC request whose `EthererumJSONRPC.DecodeError.Response` had a decode error. |
||||
""" |
||||
|
||||
@enforce_keys [:url, :body] |
||||
defstruct [:url, :body] |
||||
end |
||||
|
||||
defmodule Response do |
||||
@moduledoc """ |
||||
Ethereum JSONRPC response that had a decode error. |
||||
""" |
||||
|
||||
@enforce_keys [:status_code, :body] |
||||
defstruct [:status_code, :body] |
||||
end |
||||
|
||||
@impl Exception |
||||
def exception(named_arguments) do |
||||
request_fields = Keyword.fetch!(named_arguments, :request) |
||||
request = struct!(EthereumJSONRPC.DecodeError.Request, request_fields) |
||||
|
||||
response_fields = Keyword.fetch!(named_arguments, :response) |
||||
response = struct!(EthereumJSONRPC.DecodeError.Response, response_fields) |
||||
|
||||
%EthereumJSONRPC.DecodeError{request: request, response: response} |
||||
end |
||||
|
||||
@impl Exception |
||||
def message(%EthereumJSONRPC.DecodeError{ |
||||
request: %EthereumJSONRPC.DecodeError.Request{url: request_url, body: request_body}, |
||||
response: %EthereumJSONRPC.DecodeError.Response{status_code: response_status_code, body: response_body} |
||||
}) do |
||||
""" |
||||
Failed to decode Ethereum JSONRPC response: |
||||
|
||||
request: |
||||
|
||||
url: #{request_url} |
||||
|
||||
body: #{IO.iodata_to_binary(request_body)} |
||||
|
||||
response: |
||||
|
||||
status code: #{response_status_code} |
||||
|
||||
body: #{response_body} |
||||
""" |
||||
end |
||||
end |
@ -0,0 +1,34 @@ |
||||
defmodule EthereumJSONRPC.Geth do |
||||
@moduledoc """ |
||||
Ethereum JSONRPC methods that are only supported by [Geth](https://github.com/ethereum/go-ethereum/wiki/geth). |
||||
""" |
||||
|
||||
@behaviour EthereumJSONRPC.Variant |
||||
|
||||
@doc """ |
||||
Internal transaction fetching is not supported currently for Geth. |
||||
|
||||
To signal to the caller that fetching is not supported, `:ignore` is returned |
||||
|
||||
iex> EthereumJSONRPC.Geth.fetch_internal_transactions([ |
||||
...> "0x2ec382949ba0b22443aa4cb38267b1fb5e68e188109ac11f7a82f67571a0adf3" |
||||
...> ]) |
||||
:ignore |
||||
|
||||
""" |
||||
@impl EthereumJSONRPC.Variant |
||||
def fetch_internal_transactions(transaction_params) when is_list(transaction_params), |
||||
do: :ignore |
||||
|
||||
@doc """ |
||||
Pending transaction fetching is not supported currently for Geth. |
||||
|
||||
To signal to the caller that fetching is not supported, `:ignore` is returned |
||||
|
||||
iex> EthereumJSONRPC.Geth.fetch_pending_transactions() |
||||
:ignore |
||||
|
||||
""" |
||||
@impl EthereumJSONRPC.Variant |
||||
def fetch_pending_transactions, do: :ignore |
||||
end |
@ -0,0 +1,35 @@ |
||||
defmodule EthereumJSONRPC.Variant do |
||||
@moduledoc """ |
||||
A variant of the Ethereum JSONRPC API. Each Ethereum client supports slightly different versions of the non-standard |
||||
Ethereum JSONRPC API. The variant callbacks abstract over this difference. |
||||
""" |
||||
|
||||
alias EthereumJSONRPC.Transaction |
||||
|
||||
@type internal_transaction_params :: map() |
||||
|
||||
@doc """ |
||||
Fetches the `t:Explorer.Chain.InternalTransaction.changeset/2` params from the variant of the Ethereum JSONRPC API. |
||||
|
||||
## Returns |
||||
|
||||
* `{:ok, [internal_transaction_params]}` - internal transactions were successfully fetched for all transactions |
||||
* `{:error, reason}` - there was one or more errors with `reason` in fetching at least one of the transaction's |
||||
internal transactions |
||||
* `:ignore` - the variant does not support fetching internal transactions. |
||||
""" |
||||
@callback fetch_internal_transactions([Transaction.params()]) :: |
||||
{:ok, [internal_transaction_params]} | {:error, reason :: term} | :ignore |
||||
|
||||
@doc """ |
||||
Fetch the `t:Explorer.Chain.Transaction.changeset/2` params for pending transactions from the variant of the Ethereum |
||||
JSONRPC API. |
||||
|
||||
## Returns |
||||
|
||||
* `{:ok, [transaction_params]}` - pending transactions were succucessfully fetched |
||||
* `{:error, reason}` - there was one or more errors with `reason` in fetching the pending transactions |
||||
* `:ignore` - the variant does not support fetching pending transactions. |
||||
""" |
||||
@callback fetch_pending_transactions() :: {:ok, [Transaction.params()]} | {:error, reason :: term} | :ignore |
||||
end |
@ -1,130 +1,134 @@ |
||||
defmodule EthereumJSONRPCTest do |
||||
use ExUnit.Case, async: true |
||||
|
||||
doctest EthereumJSONRPC |
||||
import EthereumJSONRPC.Case |
||||
|
||||
describe "fetch_balances/1" do |
||||
test "with all valid hash_data returns {:ok, addresses_params}" do |
||||
assert EthereumJSONRPC.fetch_balances([ |
||||
%{block_quantity: "0x1", hash_data: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"} |
||||
]) == |
||||
{:ok, |
||||
[ |
||||
%{ |
||||
fetched_balance: 1, |
||||
fetched_balance_block_number: 1, |
||||
hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" |
||||
} |
||||
]} |
||||
end |
||||
|
||||
test "with all invalid hash_data returns {:error, reasons}" do |
||||
assert EthereumJSONRPC.fetch_balances([%{block_quantity: "0x1", hash_data: "0x0"}]) == |
||||
{:error, |
||||
[ |
||||
%{ |
||||
"blockNumber" => "0x1", |
||||
"code" => -32602, |
||||
"hash" => "0x0", |
||||
"message" => |
||||
"Invalid params: invalid length 1, expected a 0x-prefixed, padded, hex-encoded hash with length 40." |
||||
} |
||||
]} |
||||
end |
||||
@moduletag :capture_log |
||||
|
||||
test "with a mix of valid and invalid hash_data returns {:error, reasons}" do |
||||
assert EthereumJSONRPC.fetch_balances([ |
||||
# start with :ok |
||||
%{ |
||||
block_quantity: "0x1", |
||||
hash_data: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" |
||||
}, |
||||
# :ok, :ok clause |
||||
%{ |
||||
block_quantity: "0x34", |
||||
hash_data: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" |
||||
}, |
||||
# :ok, :error clause |
||||
%{ |
||||
block_quantity: "0x2", |
||||
hash_data: "0x3" |
||||
}, |
||||
# :error, :ok clause |
||||
%{ |
||||
block_quantity: "0x35", |
||||
hash_data: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" |
||||
}, |
||||
# :error, :error clause |
||||
%{ |
||||
block_quantity: "0x4", |
||||
hash_data: "0x5" |
||||
} |
||||
]) == |
||||
{:error, |
||||
[ |
||||
%{ |
||||
"blockNumber" => "0x2", |
||||
"code" => -32602, |
||||
"hash" => "0x3", |
||||
"message" => |
||||
"Invalid params: invalid length 1, expected a 0x-prefixed, padded, hex-encoded hash with length 40." |
||||
}, |
||||
%{ |
||||
"blockNumber" => "0x4", |
||||
"code" => -32602, |
||||
"hash" => "0x5", |
||||
"message" => |
||||
"Invalid params: invalid length 1, expected a 0x-prefixed, padded, hex-encoded hash with length 40." |
||||
} |
||||
]} |
||||
end |
||||
setup do |
||||
%{variant: EthereumJSONRPC.config(:variant)} |
||||
end |
||||
|
||||
describe "json_rpc/2" do |
||||
# regression test for https://github.com/poanetwork/poa-explorer/issues/254 |
||||
test "transparently splits batch payloads that would trigger a 413 Request Entity Too Large" do |
||||
block_numbers = 0..13000 |
||||
describe "fetch_balances/1" do |
||||
test "with all valid hash_data returns {:ok, addresses_params}", %{variant: variant} do |
||||
assert {:ok, |
||||
[ |
||||
%{ |
||||
fetched_balance: fetched_balance, |
||||
fetched_balance_block_number: 1, |
||||
hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" |
||||
} |
||||
]} = |
||||
EthereumJSONRPC.fetch_balances([ |
||||
%{block_quantity: "0x1", hash_data: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"} |
||||
]) |
||||
|
||||
case variant do |
||||
EthereumJSONRPC.Geth -> |
||||
assert fetched_balance == 0 |
||||
|
||||
EthereumJSONRPC.Parity -> |
||||
assert fetched_balance == 1 |
||||
|
||||
_ -> |
||||
raise ArgumentError, "Unsupported variant (#{variant}})" |
||||
end |
||||
end |
||||
|
||||
payload = |
||||
block_numbers |
||||
|> Stream.with_index() |
||||
|> Enum.map(&get_block_by_number_request/1) |
||||
test "with all invalid hash_data returns {:error, reasons}", %{variant: variant} do |
||||
assert {:error, reasons} = EthereumJSONRPC.fetch_balances([%{block_quantity: "0x1", hash_data: "0x0"}]) |
||||
assert is_list(reasons) |
||||
assert length(reasons) == 1 |
||||
|
||||
assert_payload_too_large(payload) |
||||
[reason] = reasons |
||||
|
||||
url = EthereumJSONRPC.config(:url) |
||||
assert %{ |
||||
"blockNumber" => "0x1", |
||||
"code" => -32602, |
||||
"hash" => "0x0", |
||||
"message" => message |
||||
} = reason |
||||
|
||||
assert {:ok, responses} = EthereumJSONRPC.json_rpc(payload, url) |
||||
assert Enum.count(responses) == Enum.count(block_numbers) |
||||
case variant do |
||||
EthereumJSONRPC.Geth -> |
||||
assert message == |
||||
"invalid argument 0: json: cannot unmarshal hex string of odd length into Go value of type common.Address" |
||||
|
||||
block_number_set = MapSet.new(block_numbers) |
||||
EthereumJSONRPC.Parity -> |
||||
assert message == |
||||
"Invalid params: invalid length 1, expected a 0x-prefixed, padded, hex-encoded hash with length 40." |
||||
|
||||
response_block_number_set = |
||||
Enum.into(responses, MapSet.new(), fn %{"result" => %{"number" => quantity}} -> |
||||
EthereumJSONRPC.quantity_to_integer(quantity) |
||||
end) |
||||
_ -> |
||||
raise ArgumentError, "Unsupported variant (#{variant}})" |
||||
end |
||||
end |
||||
|
||||
assert MapSet.equal?(response_block_number_set, block_number_set) |
||||
test "with a mix of valid and invalid hash_data returns {:error, reasons}" do |
||||
assert {:error, reasons} = |
||||
EthereumJSONRPC.fetch_balances([ |
||||
# start with :ok |
||||
%{ |
||||
block_quantity: "0x1", |
||||
hash_data: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" |
||||
}, |
||||
# :ok, :ok clause |
||||
%{ |
||||
block_quantity: "0x34", |
||||
hash_data: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" |
||||
}, |
||||
# :ok, :error clause |
||||
%{ |
||||
block_quantity: "0x2", |
||||
hash_data: "0x3" |
||||
}, |
||||
# :error, :ok clause |
||||
%{ |
||||
block_quantity: "0x35", |
||||
hash_data: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" |
||||
}, |
||||
# :error, :error clause |
||||
%{ |
||||
block_quantity: "0x4", |
||||
hash_data: "0x5" |
||||
} |
||||
]) |
||||
|
||||
assert is_list(reasons) |
||||
assert length(reasons) > 1 |
||||
end |
||||
end |
||||
|
||||
defp assert_payload_too_large(payload) do |
||||
json = Jason.encode_to_iodata!(payload) |
||||
headers = [{"Content-Type", "application/json"}] |
||||
url = EthereumJSONRPC.config(:url) |
||||
|
||||
assert {:ok, %HTTPoison.Response{body: body, status_code: 413}} = |
||||
HTTPoison.post(url, json, headers, EthereumJSONRPC.config(:http)) |
||||
describe "fetch_block_number_by_tag/1" do |
||||
@tag capture_log: false |
||||
test "with earliest" do |
||||
log_bad_gateway( |
||||
fn -> EthereumJSONRPC.fetch_block_number_by_tag("earliest") end, |
||||
fn result -> |
||||
assert {:ok, 0} = result |
||||
end |
||||
) |
||||
end |
||||
|
||||
assert body =~ "413 Request Entity Too Large" |
||||
end |
||||
@tag capture_log: false |
||||
test "with latest" do |
||||
log_bad_gateway( |
||||
fn -> EthereumJSONRPC.fetch_block_number_by_tag("latest") end, |
||||
fn result -> |
||||
assert {:ok, number} = result |
||||
assert number > 0 |
||||
end |
||||
) |
||||
end |
||||
|
||||
defp get_block_by_number_request({block_number, id}) do |
||||
%{ |
||||
"id" => id, |
||||
"jsonrpc" => "2.0", |
||||
"method" => "eth_getBlockByNumber", |
||||
"params" => [EthereumJSONRPC.integer_to_quantity(block_number), true] |
||||
} |
||||
@tag capture_log: false |
||||
test "with pending" do |
||||
log_bad_gateway( |
||||
fn -> EthereumJSONRPC.fetch_block_number_by_tag("pending") end, |
||||
fn result -> |
||||
assert {:ok, number} = result |
||||
assert number > 0 |
||||
end |
||||
) |
||||
end |
||||
end |
||||
end |
||||
|
@ -0,0 +1,5 @@ |
||||
defmodule EthereumJSONRPC.GethTest do |
||||
use ExUnit.Case, async: false |
||||
|
||||
doctest EthereumJSONRPC.Geth |
||||
end |
@ -0,0 +1,10 @@ |
||||
defmodule EthereumJSONRPC.Case do |
||||
require Logger |
||||
|
||||
def log_bad_gateway(under_test, assertions) do |
||||
case under_test.() do |
||||
{:error, {:bad_gateway, url}} -> Logger.error(fn -> ["Bad Gateway to ", url, ". Check CloudFlare."] end) |
||||
other -> assertions.(other) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,146 @@ |
||||
defmodule Explorer.Chain.Hash.Nonce do |
||||
@moduledoc """ |
||||
The nonce (16 (hex) characters / 128 bits / 8 bytes) is derived from the Proof-of-Work. |
||||
""" |
||||
|
||||
alias Explorer.Chain.Hash |
||||
|
||||
@behaviour Ecto.Type |
||||
@behaviour Hash |
||||
|
||||
@byte_count 8 |
||||
@hexadecimal_digit_count Hash.hexadecimal_digits_per_byte() * @byte_count |
||||
|
||||
@typedoc """ |
||||
A #{@byte_count}-byte hash of the address public key. |
||||
""" |
||||
@type t :: %Hash{byte_count: unquote(@byte_count), bytes: <<_::unquote(@byte_count * Hash.bits_per_byte())>>} |
||||
|
||||
@doc """ |
||||
Casts `term` to `t:t/0`. |
||||
|
||||
If the `term` is already in `t:t/0`, then it is returned |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.cast( |
||||
...> %Explorer.Chain.Hash{ |
||||
...> byte_count: 8, |
||||
...> bytes: <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>> |
||||
...> } |
||||
...> ) |
||||
{ |
||||
:ok, |
||||
%Explorer.Chain.Hash{ |
||||
byte_count: 8, |
||||
bytes: <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>> |
||||
} |
||||
} |
||||
|
||||
If the `term` is an `non_neg_integer`, then it is converted to `t:t/0` |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.cast(0x7bb9369dcbaec019) |
||||
{ |
||||
:ok, |
||||
%Explorer.Chain.Hash{ |
||||
byte_count: 8, |
||||
bytes: <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>> |
||||
} |
||||
} |
||||
|
||||
If the `non_neg_integer` is too large, then `:error` is returned. |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.cast(0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b) |
||||
:error |
||||
|
||||
If the `term` is a `String.t` that starts with `0x`, then is converted to an integer and then to `t:t/0`. |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.cast("0x7bb9369dcbaec019") |
||||
{ |
||||
:ok, |
||||
%Explorer.Chain.Hash{ |
||||
byte_count: 8, |
||||
bytes: <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>> |
||||
} |
||||
} |
||||
|
||||
While `non_neg_integers` don't have to be the correct width (because zero padding it difficult with numbers), |
||||
`String.t` format must always have #{@hexadecimal_digit_count} digits after the `0x` base prefix. |
||||
|
||||
iex> Explorer.Chain.Hash.Address.cast("0x0") |
||||
:error |
||||
|
||||
""" |
||||
@impl Ecto.Type |
||||
@spec cast(term()) :: {:ok, t()} | :error |
||||
def cast(term) do |
||||
Hash.cast(__MODULE__, term) |
||||
end |
||||
|
||||
@doc """ |
||||
Dumps the binary hash to `:binary` (`bytea`) format used in database. |
||||
|
||||
If the field from the struct is `t:t/0`, then it succeeds |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.dump( |
||||
...> %Explorer.Chain.Hash{ |
||||
...> byte_count: 8, |
||||
...> bytes: <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>> |
||||
...> } |
||||
...> ) |
||||
{:ok, <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>>} |
||||
|
||||
If the field from the struct is an incorrect format such as `t:Explorer.Chain.Hash.t/0`, `:error` is returned |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.dump( |
||||
...> %Explorer.Chain.Hash{ |
||||
...> byte_count: 32, |
||||
...> bytes: <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: |
||||
...> big-integer-size(32)-unit(8)>> |
||||
...> } |
||||
...> ) |
||||
:error |
||||
|
||||
""" |
||||
@impl Ecto.Type |
||||
@spec dump(term()) :: {:ok, binary} | :error |
||||
def dump(term) do |
||||
Hash.dump(__MODULE__, term) |
||||
end |
||||
|
||||
@doc """ |
||||
Loads the binary hash from the database. |
||||
|
||||
If the binary hash is the correct format, it is returned. |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.load(<<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>>) |
||||
{ |
||||
:ok, |
||||
%Explorer.Chain.Hash{ |
||||
byte_count: 8, |
||||
bytes: <<0x7bb9369dcbaec019 :: big-integer-size(8)-unit(8)>> |
||||
} |
||||
} |
||||
|
||||
If the binary hash is an incorrect format, such as if an `Explorer.Chain.Hash` field is loaded, `:error` is returned. |
||||
|
||||
iex> Explorer.Chain.Hash.Nonce.load( |
||||
...> <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: big-integer-size(32)-unit(8)>> |
||||
...> ) |
||||
:error |
||||
|
||||
""" |
||||
@impl Ecto.Type |
||||
@spec load(term()) :: {:ok, t} | :error |
||||
def load(term) do |
||||
Hash.load(__MODULE__, term) |
||||
end |
||||
|
||||
@doc """ |
||||
The underlying database type: `binary`. `binary` is used because no Postgres integer type is 20 bytes long. |
||||
""" |
||||
@impl Ecto.Type |
||||
@spec type() :: :binary |
||||
def type, do: :binary |
||||
|
||||
@impl Hash |
||||
def byte_count, do: @byte_count |
||||
end |
@ -0,0 +1,5 @@ |
||||
defmodule Explorer.Chain.Hash.AddressTest do |
||||
use ExUnit.Case, async: true |
||||
|
||||
doctest Explorer.Chain.Hash.Address |
||||
end |
@ -0,0 +1,5 @@ |
||||
defmodule Explorer.Chain.Hash.NonceTest do |
||||
use ExUnit.Case, async: true |
||||
|
||||
doctest Explorer.Chain.Hash.Nonce |
||||
end |
@ -1,5 +0,0 @@ |
||||
defmodule Explorer.Chain.Hash.TruncatedTest do |
||||
use ExUnit.Case, async: true |
||||
|
||||
doctest Explorer.Chain.Hash.Truncated |
||||
end |
@ -1,4 +0,0 @@ |
||||
# Used by "mix format" |
||||
[ |
||||
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] |
||||
] |
Loading…
Reference in new issue