account#txlistinternal by address API endpoint

Why:

* For API users to be able to get a list of internal transactions by
address.

  Example usage:
    ```
    /api?module=account&action=txlistinternal&address={addressHash}
    ```
* Issue link: https://github.com/poanetwork/blockscout/issues/138

This change addresses the need by:

* Adding `Explorer.Etherscan.list_internal_transactions/2` to get list
of internal transactions by address.
* Editing `Explorer.Etherscan` by changing the order of some of the
bindings within queries to be able to reuse `where_start_block_match/2`
and `where_end_block_match` in the new function mentioned above.
* Editing `API.RPC.AddressController.txlistinternal/2` to work when
getting internal transactions by txhash or address.
* Editing `account#txlistinternal` documentation data to account for the
fact that it can now be used with an address or txhash. Documentation
data lives in `BlockScoutWeb.Etherscan`
pull/828/head
Sebastian Abondano 6 years ago committed by Luke Imhoff
parent be5ec072b0
commit e99690eed7
  1. 49
      apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex
  2. 47
      apps/block_scout_web/lib/block_scout_web/etherscan.ex
  3. 173
      apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs
  4. 51
      apps/explorer/lib/explorer/etherscan.ex
  5. 185
      apps/explorer/test/explorer/etherscan_test.exs

@ -50,20 +50,40 @@ defmodule BlockScoutWeb.API.RPC.AddressController do
end
def txlistinternal(conn, params) do
with {:txhash_param, {:ok, txhash_param}} <- fetch_txhash(params),
{:format, {:ok, transaction_hash}} <- to_transaction_hash(txhash_param),
case {Map.fetch(params, "txhash"), Map.fetch(params, "address")} do
{:error, :error} ->
render(conn, :error, error: "Query parameter txhash or address is required")
{{:ok, txhash_param}, :error} ->
txlistinternal(conn, txhash_param, :txhash)
{:error, {:ok, address_param}} ->
txlistinternal(conn, params, address_param, :address)
end
end
def txlistinternal(conn, txhash_param, :txhash) do
with {:format, {:ok, transaction_hash}} <- to_transaction_hash(txhash_param),
{:ok, internal_transactions} <- list_internal_transactions(transaction_hash) do
render(conn, :txlistinternal, %{internal_transactions: internal_transactions})
else
{:txhash_param, :error} ->
conn
|> put_status(200)
|> render(:error, error: "Query parameter txhash is required")
{:format, :error} ->
render(conn, :error, error: "Invalid txhash format")
{:error, :not_found} ->
render(conn, :error, error: "No internal transactions found", data: [])
end
end
def txlistinternal(conn, params, address_param, :address) do
options = optional_params(params)
with {:format, {:ok, address_hash}} <- to_address_hash(address_param),
{:ok, internal_transactions} <- list_internal_transactions(address_hash, options) do
render(conn, :txlistinternal, %{internal_transactions: internal_transactions})
else
{:format, :error} ->
conn
|> put_status(200)
|> render(:error, error: "Invalid txhash format")
render(conn, :error, error: "Invalid address format")
{:error, :not_found} ->
render(conn, :error, error: "No internal transactions found", data: [])
@ -208,10 +228,6 @@ defmodule BlockScoutWeb.API.RPC.AddressController do
{:address_param, Map.fetch(params, "address")}
end
defp fetch_txhash(params) do
{:txhash_param, Map.fetch(params, "txhash")}
end
defp to_address_hashes(address_param) when is_binary(address_param) do
address_param
|> String.split(",")
@ -361,6 +377,13 @@ defmodule BlockScoutWeb.API.RPC.AddressController do
end
end
defp list_internal_transactions(transaction_hash, options) do
case Etherscan.list_internal_transactions(transaction_hash, options) do
[] -> {:error, :not_found}
internal_transactions -> {:ok, internal_transactions}
end
end
defp list_token_transfers(address_hash, contract_address_hash, options) do
case Etherscan.list_token_transfers(address_hash, contract_address_hash, options) do
[] -> {:error, :not_found}

@ -925,16 +925,55 @@ defmodule BlockScoutWeb.Etherscan do
@account_txlistinternal_action %{
name: "txlistinternal",
description: "Get internal transactions by transaction hash. Up to a maximum of 10,000 internal transactions.",
description:
"Get internal transactions by transaction or address hash. Up to a maximum of 10,000 internal transactions.",
required_params: [
%{
key: "txhash",
placeholder: "transactionHash",
type: "string",
description: "Transaction hash. Hash of contents of the transaction."
description:
"Transaction hash. Hash of contents of the transaction. A transcation hash or address hash is required."
}
],
optional_params: [
%{
key: "address",
placeholder: "addressHash",
type: "string",
description: "A 160-bit code used for identifying accounts. An address hash or transaction hash is required."
},
%{
key: "sort",
type: "string",
description:
"A string representing the order by block number direction. Defaults to ascending order. Available values: asc, desc. WARNING: Only available if 'address' is provided."
},
%{
key: "startblock",
type: "integer",
description:
"A nonnegative integer that represents the starting block number. WARNING: Only available if 'address' is provided."
},
%{
key: "endblock",
type: "integer",
description:
"A nonnegative integer that represents the ending block number. WARNING: Only available if 'address' is provided."
},
%{
key: "page",
type: "integer",
description:
"A nonnegative integer that represents the page number to be used for pagination. 'offset' must be provided in conjunction. WARNING: Only available if 'address' is provided."
},
%{
key: "offset",
type: "integer",
description:
"A nonnegative integer that represents the maximum number of records to return when paginating. 'page' must be provided in conjunction. WARNING: Only available if 'address' is provided."
}
],
optional_params: [],
responses: [
%{
code: "200",
@ -1281,7 +1320,7 @@ defmodule BlockScoutWeb.Etherscan do
@token_gettoken_action %{
name: "getToken",
description:
"Get <a href='https://github.com/ethereum/EIPs/issues/20'>ERC-20</a>" <>
"Get <a href='https://github.com/ethereum/EIPs/issues/20'>ERC-20</a> " <>
"or <a href='https://github.com/ethereum/EIPs/issues/721'>ERC-721</a> token by contract address.",
required_params: [
%{

@ -1071,23 +1071,25 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do
end
describe "txlistinternal" do
test "with missing txhash", %{conn: conn} do
test "with missing txhash and address", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlistinternal"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
response =
conn
|> get("/api", params)
|> json_response(200)
assert response["message"] =~ "txhash is required"
assert response["message"] =~ "txhash or address is required"
assert response["status"] == "0"
assert Map.has_key?(response, "result")
refute response["result"]
end
end
describe "txlistinternal with txhash" do
test "with an invalid txhash", %{conn: conn} do
params = %{
"module" => "account",
@ -1106,7 +1108,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do
refute response["result"]
end
test "with an txhash that doesn't exist", %{conn: conn} do
test "with a txhash that doesn't exist", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlistinternal",
@ -1233,6 +1235,163 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do
end
end
describe "txlistinternal with address" do
test "with an invalid address", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlistinternal",
"address" => "badhash"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert response["message"] =~ "Invalid address format"
assert response["status"] == "0"
assert Map.has_key?(response, "result")
refute response["result"]
end
test "with a address that doesn't exist", %{conn: conn} do
params = %{
"module" => "account",
"action" => "txlistinternal",
"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert response["result"] == []
assert response["status"] == "0"
assert response["message"] == "No internal transactions found"
end
test "response includes all the expected fields", %{conn: conn} do
address = insert(:address)
contract_address = insert(:contract_address)
block = insert(:block)
transaction =
:transaction
|> insert(from_address: address, to_address: nil)
|> with_contract_creation(contract_address)
|> with_block(block)
internal_transaction =
:internal_transaction_create
|> insert(transaction: transaction, index: 0, from_address: address)
|> with_contract_creation(contract_address)
params = %{
"module" => "account",
"action" => "txlistinternal",
"address" => "#{address.hash}"
}
expected_result = [
%{
"blockNumber" => "#{transaction.block_number}",
"timeStamp" => "#{DateTime.to_unix(block.timestamp)}",
"from" => "#{internal_transaction.from_address_hash}",
"to" => "#{internal_transaction.to_address_hash}",
"value" => "#{internal_transaction.value.value}",
"contractAddress" => "#{contract_address.hash}",
"input" => "",
"type" => "#{internal_transaction.type}",
"gas" => "#{internal_transaction.gas}",
"gasUsed" => "#{internal_transaction.gas_used}",
"isError" => "0",
"errCode" => "#{internal_transaction.error}"
}
]
response =
conn
|> get("/api", params)
|> json_response(200)
assert response["result"] == expected_result
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "isError is true if internal transaction has an error", %{conn: conn} do
address = insert(:address)
transaction =
:transaction
|> insert()
|> with_block()
internal_transaction_details = [
from_address: address,
transaction: transaction,
index: 0,
type: :reward,
error: "some error"
]
insert(:internal_transaction_create, internal_transaction_details)
params = %{
"module" => "account",
"action" => "txlistinternal",
"address" => "#{address.hash}"
}
assert %{"result" => [found_internal_transaction]} =
response =
conn
|> get("/api", params)
|> json_response(200)
assert found_internal_transaction["isError"] == "1"
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "with transaction with multiple internal transactions", %{conn: conn} do
address = insert(:address)
transaction =
:transaction
|> insert()
|> with_block()
for index <- 0..2 do
internal_transaction_details = %{
from_address: address,
transaction: transaction,
index: index
}
insert(:internal_transaction_create, internal_transaction_details)
end
params = %{
"module" => "account",
"action" => "txlistinternal",
"address" => "#{address.hash}"
}
assert %{"result" => found_internal_transactions} =
response =
conn
|> get("/api", params)
|> json_response(200)
assert length(found_internal_transactions) == 3
assert response["status"] == "1"
assert response["message"] == "OK"
end
end
describe "tokentx" do
test "with missing address hash", %{conn: conn} do
params = %{

@ -97,6 +97,49 @@ defmodule Explorer.Etherscan do
|> Repo.all()
end
@doc """
Gets a list of internal transactions for a given address hash
(`t:Explorer.Chain.Hash.Address.t/0`).
Note that this function relies on `Explorer.Chain` to exclude/include
internal transactions as follows:
* exclude internal transactions of type call with no siblings in the
transaction
* include internal transactions of type create, reward, or suicide
even when they are alone in the parent transaction
"""
@spec list_internal_transactions(Hash.Address.t(), map()) :: [map()]
def list_internal_transactions(
%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash,
raw_options \\ %{}
) do
options = Map.merge(@default_options, raw_options)
query =
from(
it in InternalTransaction,
inner_join: t in assoc(it, :transaction),
inner_join: b in assoc(t, :block),
order_by: [{^options.order_by_direction, t.block_number}],
limit: ^options.page_size,
offset: ^offset(options),
select:
merge(map(it, ^@internal_transaction_fields), %{
block_timestamp: b.timestamp,
block_number: b.number
})
)
query
|> Chain.where_transaction_has_multiple_internal_transactions()
|> where_address_match(address_hash, options)
|> where_start_block_match(options)
|> where_end_block_match(options)
|> Repo.all()
end
@doc """
Gets a list of token transfers for a given `t:Explorer.Chain.Hash.Address.t/0`.
@ -276,9 +319,9 @@ defmodule Explorer.Etherscan do
query =
from(
t in Transaction,
inner_join: b in assoc(t, :block),
inner_join: tt in assoc(t, :token_transfers),
inner_join: tkn in assoc(tt, :token),
inner_join: b in assoc(t, :block),
where: tt.from_address_hash == ^address_hash,
or_where: tt.to_address_hash == ^address_hash,
order_by: [{^options.order_by_direction, t.block_number}],
@ -313,19 +356,19 @@ defmodule Explorer.Etherscan do
defp where_start_block_match(query, %{start_block: nil}), do: query
defp where_start_block_match(query, %{start_block: start_block}) do
where(query, [t], t.block_number >= ^start_block)
where(query, [..., block], block.number >= ^start_block)
end
defp where_end_block_match(query, %{end_block: nil}), do: query
defp where_end_block_match(query, %{end_block: end_block}) do
where(query, [t], t.block_number <= ^end_block)
where(query, [..., block], block.number <= ^end_block)
end
defp where_contract_address_match(query, nil), do: query
defp where_contract_address_match(query, contract_address_hash) do
where(query, [_, _, tt], tt.token_contract_address_hash == ^contract_address_hash)
where(query, [_, tt], tt.token_contract_address_hash == ^contract_address_hash)
end
defp offset(options), do: (options.page_number - 1) * options.page_size

@ -414,7 +414,7 @@ defmodule Explorer.EtherscanTest do
end
end
describe "list_internal_transactions/1" do
describe "list_internal_transactions/1 with transaction hash" do
test "with empty db" do
transaction = build(:transaction)
@ -517,6 +517,189 @@ defmodule Explorer.EtherscanTest do
# These two requirements are tested in `Explorer.ChainTest`.
end
describe "list_internal_transactions/2 with address hash" do
test "with empty db" do
address = build(:address)
assert Etherscan.list_internal_transactions(address.hash) == []
end
test "response includes all the expected fields" do
address = insert(:address)
contract_address = insert(:contract_address)
block = insert(:block)
transaction =
:transaction
|> insert(from_address: address, to_address: nil)
|> with_contract_creation(contract_address)
|> with_block(block)
internal_transaction =
:internal_transaction_create
|> insert(transaction: transaction, index: 0, from_address: address)
|> with_contract_creation(contract_address)
[found_internal_transaction] = Etherscan.list_internal_transactions(address.hash)
expected = %{
block_number: block.number,
block_timestamp: block.timestamp,
from_address_hash: internal_transaction.from_address_hash,
to_address_hash: internal_transaction.to_address_hash,
value: internal_transaction.value,
created_contract_address_hash: internal_transaction.created_contract_address_hash,
input: internal_transaction.input,
type: internal_transaction.type,
gas: internal_transaction.gas,
gas_used: internal_transaction.gas_used,
error: internal_transaction.error
}
assert found_internal_transaction == expected
end
test "with address with 0 internal transactions" do
transaction =
:transaction
|> insert()
|> with_block()
assert Etherscan.list_internal_transactions(transaction.from_address_hash) == []
end
test "with address with multiple internal transactions" do
address = insert(:address)
transaction =
:transaction
|> insert()
|> with_block()
for index <- 0..2 do
internal_transaction_details = %{
transaction: transaction,
index: index,
from_address: address
}
insert(:internal_transaction, internal_transaction_details)
end
found_internal_transactions = Etherscan.list_internal_transactions(address.hash)
assert length(found_internal_transactions) == 3
end
test "only returns internal transactions associated to the given address" do
address1 = insert(:address)
address2 = insert(:address)
transaction =
:transaction
|> insert()
|> with_block()
insert(:internal_transaction, transaction: transaction, index: 0, created_contract_address: address1)
insert(:internal_transaction, transaction: transaction, index: 1, from_address: address1)
insert(:internal_transaction, transaction: transaction, index: 2, to_address: address1)
insert(:internal_transaction, transaction: transaction, index: 3, from_address: address2)
internal_transactions1 = Etherscan.list_internal_transactions(address1.hash)
assert length(internal_transactions1) == 3
internal_transactions2 = Etherscan.list_internal_transactions(address2.hash)
assert length(internal_transactions2) == 1
end
test "with pagination options" do
address = insert(:address)
transaction =
:transaction
|> insert()
|> with_block()
for index <- 0..2 do
internal_transaction_details = %{
transaction: transaction,
index: index,
from_address: address
}
insert(:internal_transaction, internal_transaction_details)
end
options1 = %{
page_number: 1,
page_size: 2
}
found_internal_transactions1 = Etherscan.list_internal_transactions(address.hash, options1)
assert length(found_internal_transactions1) == 2
options2 = %{
page_number: 2,
page_size: 2
}
found_internal_transactions2 = Etherscan.list_internal_transactions(address.hash, options2)
assert length(found_internal_transactions2) == 1
end
test "with start and end block options" do
blocks = [_, second_block, third_block, _] = insert_list(4, :block)
address = insert(:address)
for block <- blocks, index <- 0..1 do
transaction =
:transaction
|> insert()
|> with_block(block)
internal_transaction_details = %{
transaction: transaction,
index: index,
from_address: address
}
insert(:internal_transaction, internal_transaction_details)
end
options = %{
start_block: second_block.number,
end_block: third_block.number
}
found_internal_transactions = Etherscan.list_internal_transactions(address.hash, options)
expected_block_numbers = [second_block.number, third_block.number]
assert length(found_internal_transactions) == 4
for internal_transaction <- found_internal_transactions do
assert internal_transaction.block_number in expected_block_numbers
end
end
# Note that `list_internal_transactions/2` relies on
# `Chain.where_transaction_has_multiple_transactions/1` to ensure the
# following behavior:
#
# * exclude internal transactions of type call with no siblings in the
# transaction
#
# * include internal transactions of type create, reward, or suicide
# even when they are alone in the parent transaction
#
# These two requirements are tested in `Explorer.ChainTest`.
end
describe "list_token_transfers/2" do
test "with empty db" do
address = build(:address)

Loading…
Cancel
Save