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 732f24022e..6569dbbf2e 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 @@ -49,10 +49,35 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end + def txlistinternal(conn, params) do + with {:txhash_param, {:ok, txhash_param}} <- fetch_txhash(params), + {: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} -> + conn + |> put_status(200) + |> render(:error, error: "Invalid txhash format") + + {:error, :not_found} -> + render(conn, :error, error: "No internal transactions found", data: []) + end + end + defp fetch_address(params) 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(",") @@ -112,6 +137,10 @@ defmodule BlockScoutWeb.API.RPC.AddressController do {:format, Chain.string_to_address_hash(address_hash_string)} end + defp to_transaction_hash(transaction_hash_string) do + {:format, Chain.string_to_transaction_hash(transaction_hash_string)} + end + defp optional_params(params) do %{} |> put_order_by_direction(params) @@ -175,4 +204,11 @@ defmodule BlockScoutWeb.API.RPC.AddressController do transactions -> {:ok, transactions} end end + + defp list_internal_transactions(transaction_hash) do + case Etherscan.list_internal_transactions(transaction_hash) do + [] -> {:error, :not_found} + internal_transactions -> {:ok, internal_transactions} + end + end end 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 10ccec507f..18da949f30 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -37,7 +37,7 @@ defmodule BlockScoutWeb.Etherscan do @account_txlist_example_value %{ "status" => "1", "message" => "OK", - result: [ + "result" => [ %{ "blockNumber" => "65204", "timeStamp" => "1439232889", @@ -68,10 +68,37 @@ defmodule BlockScoutWeb.Etherscan do "result" => [] } + @account_txlistinternal_example_value %{ + "status" => "1", + "message" => "OK", + "result" => [ + %{ + "blockNumber" => "6153702", + "timeStamp" => "1534362606", + "from" => "0x2ca1e3f250f56f1761b9a52bc42db53986085eff", + "to" => "", + "value" => "5488334153118633", + "contractAddress" => "0x883103875d905c11f9ac7dacbfc16deb39655361", + "input" => "", + "type" => "create", + "gas" => "814937", + "gasUsed" => "536262", + "isError" => "0", + "errCode" => "" + } + ] + } + + @account_txlistinternal_example_value_error %{ + "status" => "0", + "message" => "No internal transactions found", + "result" => [] + } + @logs_getlogs_example_value %{ "status" => "1", "message" => "OK", - result: [ + "result" => [ %{ "address" => "0x33990122638b9132ca29c723bdf037f1a891a70c", "topics" => [ @@ -199,6 +226,44 @@ defmodule BlockScoutWeb.Etherscan do } } + @internal_transaction %{ + name: "InternalTransaction", + fields: %{ + blockNumber: @block_number_type, + timeStamp: %{ + type: "timestamp", + definition: "The transaction's block-timestamp.", + example: ~s("1439232889") + }, + from: @address_hash_type, + to: @address_hash_type, + value: @wei_type, + contractAddress: @address_hash_type, + input: %{ + type: "input", + definition: "Data sent along with the call. A variable-byte-length binary.", + example: ~s("0x797af627d02e23b68e085092cd0d47d6cfb54be025f37b5989c0264398f534c08af7dea9") + }, + type: %{ + type: "type", + definition: ~s(Possible values: "create", "call", "reward", or "suicide"), + example: ~s("create") + }, + gas: @gas_type, + gasUsed: @gas_type, + isError: %{ + type: "error", + enum: ~s(["0", "1"]), + enum_interpretation: %{"0" => "ok", "1" => "rejected/cancelled"} + }, + errCode: %{ + type: "string", + definition: "Error message when call type error.", + example: ~s("Out of gas") + } + } + } + @log %{ name: "Log", fields: %{ @@ -383,6 +448,43 @@ 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.", + required_params: [ + %{ + key: "txhash", + placeholder: "transactionHash", + type: "string", + description: "Transaction hash. Hash of contents of the transaction." + } + ], + optional_params: [], + responses: [ + %{ + code: "200", + description: "successful operation", + example_value: Jason.encode!(@account_txlistinternal_example_value), + model: %{ + name: "Result", + fields: %{ + status: @status_type, + message: @message_type, + result: %{ + type: "array", + array_type: @internal_transaction + } + } + } + }, + %{ + code: "200", + description: "error", + example_value: Jason.encode!(@account_txlistinternal_example_value_error) + } + ] + } + @logs_getlogs_action %{ name: "getLogs", description: "Get event logs for an address and/or topics. Up to a maximum of 1,000 event logs.", @@ -503,7 +605,8 @@ defmodule BlockScoutWeb.Etherscan do actions: [ @account_balance_action, @account_balancemulti_action, - @account_txlist_action + @account_txlist_action, + @account_txlistinternal_action ] } diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex index 3796e24924..b6e0a26cbb 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex @@ -28,6 +28,11 @@ defmodule BlockScoutWeb.API.RPC.AddressView do RPCView.render("show.json", data: data) end + def render("txlistinternal.json", %{internal_transactions: internal_transactions}) do + data = Enum.map(internal_transactions, &prepare_internal_transaction/1) + RPCView.render("show.json", data: data) + end + def render("error.json", assigns) do RPCView.render("error.json", assigns) end @@ -54,4 +59,21 @@ defmodule BlockScoutWeb.API.RPC.AddressView do "confirmations" => "#{transaction.confirmations}" } end + + defp prepare_internal_transaction(internal_transaction) do + %{ + "blockNumber" => "#{internal_transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(internal_transaction.block_timestamp)}", + "from" => "#{internal_transaction.from_address_hash}", + "to" => "#{internal_transaction.to_address_hash}", + "value" => "#{internal_transaction.value.value}", + "contractAddress" => "#{internal_transaction.created_contract_address_hash}", + "input" => "#{internal_transaction.input}", + "type" => "#{internal_transaction.type}", + "gas" => "#{internal_transaction.gas}", + "gasUsed" => "#{internal_transaction.gas_used}", + "isError" => if(internal_transaction.error, do: "1", else: "0"), + "errCode" => "#{internal_transaction.error}" + } + end end 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 b7abf36ff1..c2fd757796 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 @@ -1010,4 +1010,167 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert get_response == post_response end end + + describe "txlistinternal" do + test "with missing txhash", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "txlistinternal" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "txhash is required" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with an invalid txhash", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "badhash" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid txhash format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with an txhash that doesn't exist", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "0x40eb908387324f2b575b4879cd9d7188f69c8fc9d87c901b9e2daaea4b442170" + } + + 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", + "txhash" => "#{transaction.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}" + } + ] + + assert 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 + transaction = + :transaction + |> insert() + |> with_block() + + internal_transaction_details = [ + transaction: transaction, + index: 0, + type: :reward, + error: "some error" + ] + + insert(:internal_transaction_create, internal_transaction_details) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.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 + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..2 do + insert(:internal_transaction_create, transaction: transaction, index: index) + end + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.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 end diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 44b73cd09d..a388a0a07a 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -1461,7 +1461,17 @@ defmodule Explorer.Chain do where(query, [transaction], transaction.index < ^index) end - defp where_transaction_has_multiple_internal_transactions(query) do + @doc """ + Ensures the following conditions are true: + + * excludes internal transactions of type call with no siblings in the + transaction + * includes internal transactions of type create, reward, or suicide + even when they are alone in the parent transaction + + """ + @spec where_transaction_has_multiple_internal_transactions(Ecto.Query.t()) :: Ecto.Query.t() + def where_transaction_has_multiple_internal_transactions(query) do where( query, [internal_transaction, transaction], diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index 31f3ddfee6..f0e6663384 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -7,7 +7,7 @@ defmodule Explorer.Etherscan do alias Explorer.Etherscan.Logs alias Explorer.{Repo, Chain} - alias Explorer.Chain.{Hash, Transaction} + alias Explorer.Chain.{Hash, InternalTransaction, Transaction} @default_options %{ order_by_direction: :asc, @@ -27,7 +27,7 @@ defmodule Explorer.Etherscan do end @doc """ - Gets a list of transactions for a given `t:Explorer.Chain.Hash.Address`. + Gets a list of transactions for a given `t:Explorer.Chain.Hash.Address.t/0`. """ @spec list_transactions(Hash.Address.t()) :: [map()] @@ -45,23 +45,69 @@ defmodule Explorer.Etherscan do end end - @transaction_fields [ - :block_hash, - :block_number, - :created_contract_address_hash, - :cumulative_gas_used, - :from_address_hash, - :gas, - :gas_price, - :gas_used, - :hash, - :index, - :input, - :nonce, - :status, - :to_address_hash, - :value - ] + @internal_transaction_fields ~w( + from_address_hash + to_address_hash + value + created_contract_address_hash + input + type + gas + gas_used + error + )a + + @doc """ + Gets a list of internal transactions for a given transaction hash + (`t:Explorer.Chain.Hash.Full.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.Full.t()) :: [map()] + def list_internal_transactions(%Hash{byte_count: unquote(Hash.Full.byte_count())} = transaction_hash) do + query = + from( + it in InternalTransaction, + inner_join: t in assoc(it, :transaction), + inner_join: b in assoc(t, :block), + where: it.transaction_hash == ^transaction_hash, + limit: 10_000, + select: + merge(map(it, ^@internal_transaction_fields), %{ + block_timestamp: b.timestamp, + block_number: b.number + }) + ) + + query + |> Chain.where_transaction_has_multiple_internal_transactions() + |> Repo.all() + end + + @transaction_fields ~w( + block_hash + block_number + created_contract_address_hash + cumulative_gas_used + from_address_hash + gas + gas_price + gas_used + hash + index + input + nonce + status + to_address_hash + value + )a defp list_transactions(address_hash, max_block_number, options) do query = diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 675a66b11b..037b9c01cc 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -853,6 +853,32 @@ defmodule Explorer.ChainTest do assert actual.id == expected.id end + test "includes internal transactions of type `reward` even when they are alone in the parent transaction" do + transaction = + :transaction + |> insert() + |> with_block() + + expected = insert(:internal_transaction, index: 0, transaction: transaction, type: :reward) + + actual = Enum.at(Chain.transaction_to_internal_transactions(transaction), 0) + + assert actual.id == expected.id + end + + test "includes internal transactions of type `suicide` even when they are alone in the parent transaction" do + transaction = + :transaction + |> insert() + |> with_block() + + expected = insert(:internal_transaction, index: 0, transaction: transaction, gas: nil, type: :suicide) + + actual = Enum.at(Chain.transaction_to_internal_transactions(transaction), 0) + + assert actual.id == expected.id + end + test "returns the internal transactions in ascending index order" do transaction = :transaction diff --git a/apps/explorer/test/explorer/etherscan_test.exs b/apps/explorer/test/explorer/etherscan_test.exs index aaa32d742d..cb574d9a71 100644 --- a/apps/explorer/test/explorer/etherscan_test.exs +++ b/apps/explorer/test/explorer/etherscan_test.exs @@ -6,7 +6,7 @@ defmodule Explorer.EtherscanTest do alias Explorer.{Etherscan, Chain} alias Explorer.Chain.Transaction - describe "list_transactions/1" do + describe "list_transactions/2" do test "with empty db" do address = build(:address) @@ -344,4 +344,107 @@ defmodule Explorer.EtherscanTest do end end end + + describe "list_internal_transactions/1" do + test "with empty db" do + transaction = build(:transaction) + + assert Etherscan.list_internal_transactions(transaction.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(transaction.hash) + + assert found_internal_transaction.block_number == block.number + assert found_internal_transaction.block_timestamp == block.timestamp + assert found_internal_transaction.from_address_hash == internal_transaction.from_address_hash + assert found_internal_transaction.to_address_hash == internal_transaction.to_address_hash + assert found_internal_transaction.value == internal_transaction.value + + assert found_internal_transaction.created_contract_address_hash == + internal_transaction.created_contract_address_hash + + assert found_internal_transaction.input == internal_transaction.input + assert found_internal_transaction.type == internal_transaction.type + assert found_internal_transaction.gas == internal_transaction.gas + assert found_internal_transaction.gas_used == internal_transaction.gas_used + assert found_internal_transaction.error == internal_transaction.error + end + + test "with transaction with 0 internal transactions" do + transaction = + :transaction + |> insert() + |> with_block() + + assert Etherscan.list_internal_transactions(transaction.hash) == [] + end + + test "with transaction with multiple internal transactions" do + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..2 do + insert(:internal_transaction, transaction: transaction, index: index) + end + + found_internal_transactions = Etherscan.list_internal_transactions(transaction.hash) + + assert length(found_internal_transactions) == 3 + end + + test "only returns internal transactions that belong to the transaction" do + transaction1 = + :transaction + |> insert() + |> with_block() + + transaction2 = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, transaction: transaction1, index: 0) + insert(:internal_transaction, transaction: transaction1, index: 1) + insert(:internal_transaction, transaction: transaction2, index: 0, type: :reward) + + internal_transactions1 = Etherscan.list_internal_transactions(transaction1.hash) + + assert length(internal_transactions1) == 2 + + internal_transactions2 = Etherscan.list_internal_transactions(transaction2.hash) + + assert length(internal_transactions2) == 1 + end + + # Note that `list_internal_transactions/1` 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 end