Add account transactions API RPC endpoint

Why:

* For users to be able to get the transactions for a given address hash.
  Support for optional parameters will be added in an upcoming PR.
* Issue link: https://github.com/poanetwork/poa-explorer/issues/138

This change addresses the need by:

* Adding `Explorer.Etherscan` context, where we want to keep
etherscan-only logic within the Explorer app. This is because we want to
mimic the way their RPC API works. The only function in this module for
now is `list_transactions/1`, which gets all transactions for a given
address hash.
* Adding `txlist/2` action to `API.RPC.AddressController`, to handle
account transactions requests.
  Example usage:
  ```
  api?module=account&action=txlist \
  &address=0xddbd2b932c763ba5b1b7ae3b362eac3e8d40121a
  ```
* Editing `API.RPC.AddressView` and `API.RPC.RPCView` to format
responses for new API endpoint as required.
* Editing `.credo.exs` to allow for `Hash.Address.t()` alias usage in
`Explorer.Etherscan` without Credo complaining with:
  ```
    Software Design
  ┃
  ┃ [D] ↓ Nested modules could be aliased at the top of the invoking module.
  ┃       apps/explorer/lib/explorer/etherscan.ex:19:51
  ```
pull/427/head
Sebastian Abondano 6 years ago
parent 9af847f7bb
commit e8a9b63d7b
  1. 3
      .credo.exs
  2. 67
      apps/explorer/lib/explorer/etherscan.ex
  3. 175
      apps/explorer/test/explorer/etherscan_test.exs
  4. 34
      apps/explorer_web/lib/explorer_web/controllers/api/rpc/address_controller.ex
  5. 32
      apps/explorer_web/lib/explorer_web/views/api/rpc/address_view.ex
  6. 4
      apps/explorer_web/lib/explorer_web/views/api/rpc/rpc_view.ex
  7. 199
      apps/explorer_web/test/explorer_web/controllers/api/rpc/address_controller_test.exs

@ -74,8 +74,7 @@
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
excluded_lastnames: ~w(DateTime Full Number Repo Time Truncated), priority: :low},
{Credo.Check.Design.AliasUsage, excluded_lastnames: ~w(DateTime Full Number Repo Time Address), priority: :low},
# For some checks, you can also set other parameters
#

@ -0,0 +1,67 @@
defmodule Explorer.Etherscan do
@moduledoc """
The etherscan context.
"""
import Ecto.Query,
only: [
from: 2
]
alias Explorer.{Repo, Chain}
alias Explorer.Chain.{Hash, Transaction}
@doc """
Gets a list of transactions for a given `t:Explorer.Chain.Hash.Address`.
"""
@spec list_transactions(Hash.Address.t()) :: [map()]
def list_transactions(%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash) do
case Chain.max_block_number() do
{:ok, max_block_number} ->
list_transactions(address_hash, max_block_number)
_ ->
[]
end
end
@transaction_fields [
:block_number,
:hash,
:nonce,
:block_hash,
:index,
:from_address_hash,
:to_address_hash,
:value,
:gas,
:gas_price,
:status,
:input,
:cumulative_gas_used,
:gas_used
]
defp list_transactions(address_hash, max_block_number) do
query =
from(
t in Transaction,
inner_join: b in assoc(t, :block),
left_join: it in assoc(t, :internal_transactions),
where: t.to_address_hash == ^address_hash,
or_where: t.from_address_hash == ^address_hash,
or_where: it.transaction_hash == t.hash and it.type == ^"create",
order_by: [asc: t.block_number],
limit: 10_000,
select:
merge(map(t, ^@transaction_fields), %{
block_timestamp: b.timestamp,
created_contract_address_hash: it.created_contract_address_hash,
confirmations: fragment("? - ?", ^max_block_number, t.block_number)
})
)
Repo.all(query)
end
end

@ -0,0 +1,175 @@
defmodule Explorer.EtherscanTest do
use Explorer.DataCase
import Explorer.Factory
alias Explorer.{Etherscan, Chain}
alias Explorer.Chain.Transaction
describe "list_transactions/1" do
test "with empty db" do
address = build(:address)
assert Etherscan.list_transactions(address.hash) == []
end
test "with from address" do
address = insert(:address)
transaction =
:transaction
|> insert(from_address: address)
|> with_block()
[found_transaction] = Etherscan.list_transactions(address.hash)
assert transaction.hash == found_transaction.hash
end
test "with to address" do
address = insert(:address)
transaction =
:transaction
|> insert(to_address: address)
|> with_block()
[found_transaction] = Etherscan.list_transactions(address.hash)
assert transaction.hash == found_transaction.hash
end
test "with same to and from address" do
address = insert(:address)
_transaction =
:transaction
|> insert(from_address: address, to_address: address)
|> with_block()
found_transactions = Etherscan.list_transactions(address.hash)
assert length(found_transactions) == 1
end
test "with created contract address" do
address = insert(:address)
transaction =
:transaction
|> insert(from_address: address)
|> with_block()
%{created_contract_address_hash: contract_address_hash} =
insert(:internal_transaction_create, transaction: transaction, index: 0)
[found_transaction] = Etherscan.list_transactions(contract_address_hash)
assert found_transaction.hash == transaction.hash
end
test "with address with 0 transactions" do
address1 = insert(:address)
address2 = insert(:address)
:transaction
|> insert(from_address: address2)
|> with_block()
assert Etherscan.list_transactions(address1.hash) == []
end
test "with address with multiple transactions" do
address1 = insert(:address)
address2 = insert(:address)
3
|> insert_list(:transaction, from_address: address1)
|> with_block()
:transaction
|> insert(from_address: address2)
|> with_block()
found_transactions = Etherscan.list_transactions(address1.hash)
assert length(found_transactions) == 3
for found_transaction <- found_transactions do
assert found_transaction.from_address_hash == address1.hash
end
end
test "includes confirmations value" do
insert(:block)
address = insert(:address)
transaction =
:transaction
|> insert(from_address: address)
|> with_block()
insert(:block)
[found_transaction] = Etherscan.list_transactions(address.hash)
{:ok, max_block_number} = Chain.max_block_number()
expected_confirmations = max_block_number - transaction.block_number
assert found_transaction.confirmations == expected_confirmations
end
test "loads created_contract_address_hash if available" do
address = insert(:address)
transaction =
:transaction
|> insert(from_address: address)
|> with_block()
%{created_contract_address_hash: contract_hash} =
insert(:internal_transaction_create, transaction: transaction, index: 0)
[found_transaction] = Etherscan.list_transactions(address.hash)
assert found_transaction.created_contract_address_hash == contract_hash
end
test "loads block_timestamp" do
address = insert(:address)
%Transaction{block: block} =
:transaction
|> insert(from_address: address)
|> with_block()
[found_transaction] = Etherscan.list_transactions(address.hash)
assert found_transaction.block_timestamp == block.timestamp
end
test "orders transactions by block, in ascending order" do
first_block = insert(:block)
second_block = insert(:block)
address = insert(:address)
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block()
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
found_transactions = Etherscan.list_transactions(address.hash)
block_numbers_order = Enum.map(found_transactions, & &1.block_number)
assert block_numbers_order == Enum.sort(block_numbers_order)
end
end
end

@ -1,7 +1,7 @@
defmodule ExplorerWeb.API.RPC.AddressController do
use ExplorerWeb, :controller
alias Explorer.Chain
alias Explorer.{Etherscan, Chain}
alias Explorer.Chain.{Address, Wei}
def balance(conn, params, template \\ :balance) do
@ -26,6 +26,27 @@ defmodule ExplorerWeb.API.RPC.AddressController do
balance(conn, params, :balancemulti)
end
def txlist(conn, params) do
with {:address_param, {:ok, address_param}} <- fetch_address(params),
{:format, {:ok, address_hash}} <- to_address_hash(address_param),
{:ok, transactions} <- list_transactions(address_hash) do
render(conn, :txlist, %{transactions: transactions})
else
{:address_param, :error} ->
conn
|> put_status(400)
|> render(:error, error: "Query parameter 'address' is required")
{:format, :error} ->
conn
|> put_status(400)
|> render(:error, error: "Invalid address format")
{:error, :not_found} ->
render(conn, :error, error: "No transactions found", data: [])
end
end
defp fetch_address(params) do
{:address_param, Map.fetch(params, "address")}
end
@ -84,4 +105,15 @@ defmodule ExplorerWeb.API.RPC.AddressController do
}
end)
end
defp to_address_hash(address_hash_string) do
{:format, Chain.string_to_address_hash(address_hash_string)}
end
defp list_transactions(address_hash) do
case Etherscan.list_transactions(address_hash) do
[] -> {:error, :not_found}
transactions -> {:ok, transactions}
end
end
end

@ -24,11 +24,39 @@ defmodule ExplorerWeb.API.RPC.AddressView do
RPCView.render("show.json", data: data)
end
def render("error.json", %{error: error}) do
RPCView.render("error.json", error: error)
def render("txlist.json", %{transactions: transactions}) do
data = Enum.map(transactions, &prepare_transaction/1)
RPCView.render("show.json", data: data)
end
def render("error.json", assigns) do
RPCView.render("error.json", assigns)
end
defp wei_to_ether(wei) do
format_wei_value(wei, :ether, include_unit_label: false)
end
defp prepare_transaction(transaction) do
%{
"blockNumber" => "#{transaction.block_number}",
"timeStamp" => "#{DateTime.to_unix(transaction.block_timestamp)}",
"hash" => "#{transaction.hash}",
"nonce" => "#{transaction.nonce}",
"blockHash" => "#{transaction.block_hash}",
"transactionIndex" => "#{transaction.index}",
"from" => "#{transaction.from_address_hash}",
"to" => "#{transaction.to_address_hash}",
"value" => "#{transaction.value.value}",
"gas" => "#{transaction.gas}",
"gasPrice" => "#{transaction.gas_price.value}",
"isError" => if(transaction.status == :ok, do: "0", else: "1"),
"txreceipt_status" => if(transaction.status == :ok, do: "1", else: "0"),
"input" => "#{transaction.input}",
"contractAddress" => "#{transaction.created_contract_address_hash}",
"cumulativeGasUsed" => "#{transaction.cumulative_gas_used}",
"gasUsed" => "#{transaction.gas_used}",
"confirmations" => "#{transaction.confirmations}"
}
end
end

@ -9,11 +9,11 @@ defmodule ExplorerWeb.API.RPC.RPCView do
}
end
def render("error.json", %{error: message}) do
def render("error.json", %{error: message} = assigns) do
%{
"status" => "0",
"message" => message,
"result" => nil
"result" => Map.get(assigns, :data)
}
end
end

@ -1,7 +1,8 @@
defmodule ExplorerWeb.API.RPC.AddressControllerTest do
use ExplorerWeb.ConnCase
alias Explorer.Chain.Wei
alias Explorer.Chain
alias Explorer.Chain.{Transaction, Wei}
describe "balance" do
test "with missing address hash", %{conn: conn} do
@ -284,4 +285,200 @@ defmodule ExplorerWeb.API.RPC.AddressControllerTest do
assert response["message"] == "OK"
end
end
describe "txlist" do
test "with missing address hash", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlist"
}
assert response =
conn
|> get("/api", params)
|> json_response(400)
assert response["message"] =~ "'address' is required"
assert response["status"] == "0"
assert Map.has_key?(response, "result")
refute response["result"]
end
test "with an invalid address hash", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlist",
"address" => "badhash"
}
assert response =
conn
|> get("/api", params)
|> json_response(400)
assert response["message"] =~ "Invalid address format"
assert response["status"] == "0"
assert Map.has_key?(response, "result")
refute response["result"]
end
test "with an address that doesn't exist", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlist",
"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert response["result"] == []
assert response["status"] == "0"
assert response["message"] == "No transactions found"
end
test "with a valid address", %{conn: conn} do
address = insert(:address)
transaction =
%Transaction{block: block} =
:transaction
|> insert(from_address: address)
|> with_block(status: :ok)
# ^ 'status: :ok' means `isError` in response should be '0'
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}"
}
expected_result = [
%{
"blockNumber" => "#{transaction.block_number}",
"timeStamp" => "#{DateTime.to_unix(block.timestamp)}",
"hash" => "#{transaction.hash}",
"nonce" => "#{transaction.nonce}",
"blockHash" => "#{block.hash}",
"transactionIndex" => "#{transaction.index}",
"from" => "#{transaction.from_address_hash}",
"to" => "#{transaction.to_address_hash}",
"value" => "#{transaction.value.value}",
"gas" => "#{transaction.gas}",
"gasPrice" => "#{transaction.gas_price.value}",
"isError" => "0",
"txreceipt_status" => "1",
"input" => "#{transaction.input}",
"contractAddress" => "#{transaction.created_contract_address_hash}",
"cumulativeGasUsed" => "#{transaction.cumulative_gas_used}",
"gasUsed" => "#{transaction.gas_used}",
"confirmations" => "0"
}
]
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert response["result"] == expected_result
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "includes correct confirmations value", %{conn: conn} do
insert(:block)
address = insert(:address)
transaction =
%Transaction{hash: hash} =
:transaction
|> insert(from_address: address)
|> with_block()
insert(:block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}"
}
{:ok, max_block_number} = Chain.max_block_number()
expected_confirmations = max_block_number - transaction.block_number
assert %{"result" => [returned_transaction]} =
conn
|> get("/api", params)
|> json_response(200)
assert returned_transaction["confirmations"] == "#{expected_confirmations}"
assert returned_transaction["hash"] == "#{hash}"
end
test "returns '1' for 'isError' with failed transaction", %{conn: conn} do
address = insert(:address)
%Transaction{hash: hash} =
:transaction
|> insert(from_address: address)
|> with_block(status: :error)
# ^ 'status: :error' means `isError` in response should be '1'
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}"
}
assert %{"result" => [returned_transaction]} =
conn
|> get("/api", params)
|> json_response(200)
assert returned_transaction["isError"] == "1"
assert returned_transaction["txreceipt_status"] == "0"
assert returned_transaction["hash"] == "#{hash}"
end
test "with address with multiple transactions", %{conn: conn} do
address1 = insert(:address)
address2 = insert(:address)
transactions =
3
|> insert_list(:transaction, from_address: address1)
|> with_block()
:transaction
|> insert(from_address: address2)
|> with_block()
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address1.hash}"
}
expected_transaction_hashes = Enum.map(transactions, &"#{&1.hash}")
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 3
for returned_transaction <- response["result"] do
assert returned_transaction["hash"] in expected_transaction_hashes
end
assert response["status"] == "1"
assert response["message"] == "OK"
end
end
end

Loading…
Cancel
Save