diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex
index 78dd4b9457..fbad91a77f 100644
--- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex
+++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex
@@ -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}
diff --git a/apps/block_scout_web/lib/block_scout_web/etherscan.ex b/apps/block_scout_web/lib/block_scout_web/etherscan.ex
index 43c8174401..b03eb43618 100644
--- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex
+++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex
@@ -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 ERC-20" <>
+ "Get ERC-20 " <>
"or ERC-721 token by contract address.",
required_params: [
%{
diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs
index 7b6f45cfba..b37c5a1c03 100644
--- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs
+++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs
@@ -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 = %{
diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex
index 9168cefd34..7a0791500a 100644
--- a/apps/explorer/lib/explorer/etherscan.ex
+++ b/apps/explorer/lib/explorer/etherscan.ex
@@ -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
diff --git a/apps/explorer/test/explorer/etherscan_test.exs b/apps/explorer/test/explorer/etherscan_test.exs
index 210e30580a..021f7bd9cc 100644
--- a/apps/explorer/test/explorer/etherscan_test.exs
+++ b/apps/explorer/test/explorer/etherscan_test.exs
@@ -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)