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)