diff --git a/apps/block_scout_web/assets/__tests__/pages/address.js b/apps/block_scout_web/assets/__tests__/pages/address.js index fe6506e22b..5e87b5b73d 100644 --- a/apps/block_scout_web/assets/__tests__/pages/address.js +++ b/apps/block_scout_web/assets/__tests__/pages/address.js @@ -1,56 +1,28 @@ import { reducer, initialState } from '../../js/pages/address' describe('RECEIVED_NEW_BLOCK', () => { - test('with new block', () => { - const state = Object.assign({}, initialState, { - validationCount: 30, - validatedBlocks: [{ blockNumber: 1, blockHtml: 'test 1' }] - }) + test('increases validation count', () => { + const state = Object.assign({}, initialState, { validationCount: 30 }) const action = { type: 'RECEIVED_NEW_BLOCK', - msg: { blockNumber: 2, blockHtml: 'test 2' } + blockHtml: 'test 2' } const output = reducer(state, action) expect(output.validationCount).toEqual(31) - expect(output.validatedBlocks).toEqual([ - { blockNumber: 2, blockHtml: 'test 2' }, - { blockNumber: 1, blockHtml: 'test 1' } - ]) }) - test('when channel has been disconnected', () => { + test('when channel has been disconnected does not increase validation count', () => { const state = Object.assign({}, initialState, { channelDisconnected: true, - validationCount: 30, - validatedBlocks: [{ blockNumber: 1, blockHtml: 'test 1' }] + validationCount: 30 }) const action = { type: 'RECEIVED_NEW_BLOCK', - msg: { blockNumber: 2, blockHtml: 'test 2' } + blockHtml: 'test 2' } const output = reducer(state, action) expect(output.validationCount).toEqual(30) - expect(output.validatedBlocks).toEqual([ - { blockNumber: 1, blockHtml: 'test 1' } - ]) - }) - test('beyond page one', () => { - const state = Object.assign({}, initialState, { - beyondPageOne: true, - validationCount: 30, - validatedBlocks: [{ blockNumber: 1, blockHtml: 'test 1' }] - }) - const action = { - type: 'RECEIVED_NEW_BLOCK', - msg: { blockNumber: 2, blockHtml: 'test 2' } - } - const output = reducer(state, action) - - expect(output.validationCount).toEqual(31) - expect(output.validatedBlocks).toEqual([ - { blockNumber: 1, blockHtml: 'test 1' } - ]) }) }) diff --git a/apps/block_scout_web/assets/__tests__/pages/address/validations.js b/apps/block_scout_web/assets/__tests__/pages/address/validations.js new file mode 100644 index 0000000000..f8bf284029 --- /dev/null +++ b/apps/block_scout_web/assets/__tests__/pages/address/validations.js @@ -0,0 +1,46 @@ +import { reducer, initialState } from '../../../js/pages/address/validations' + +describe('RECEIVED_NEW_BLOCK', () => { + test('adds new block to the top of the list', () => { + const state = Object.assign({}, initialState, { + items: ['test 1'] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + blockHtml: 'test 2' + } + const output = reducer(state, action) + + expect(output.items).toEqual(['test 2', 'test 1']) + }) + + test('does nothing beyond page one', () => { + const state = Object.assign({}, initialState, { + beyondPageOne: true, + channelDisconnected: false, + items: ['test 1'] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + blockHtml: 'test 2' + } + const output = reducer(state, action) + + expect(output.items).toEqual(['test 1']) + }) + + test('does nothing when channel has been disconnected', () => { + const state = Object.assign({}, initialState, { + channelDisconnected: true, + items: ['test 1'] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + blockHtml: 'test 2' + } + const output = reducer(state, action) + + expect(output.items).toEqual(['test 1']) + }) +}) + diff --git a/apps/block_scout_web/assets/js/app.js b/apps/block_scout_web/assets/js/app.js index 8f084b5521..e1fcc24b45 100644 --- a/apps/block_scout_web/assets/js/app.js +++ b/apps/block_scout_web/assets/js/app.js @@ -21,6 +21,7 @@ import 'bootstrap' import './locale' import './pages/address' +import './pages/address/validations' import './pages/blocks' import './pages/chain' import './pages/pending_transactions' diff --git a/apps/block_scout_web/assets/js/pages/address.js b/apps/block_scout_web/assets/js/pages/address.js index 683fc66a57..ce790e04b5 100644 --- a/apps/block_scout_web/assets/js/pages/address.js +++ b/apps/block_scout_web/assets/js/pages/address.js @@ -26,7 +26,6 @@ export const initialState = { transactions: [], internalTransactions: [], internalTransactionsBatch: [], - validatedBlocks: [], beyondPageOne: null, @@ -53,15 +52,7 @@ function baseReducer (state = initialState, action) { if (state.channelDisconnected) return state const validationCount = state.validationCount + 1 - - if (state.beyondPageOne) return Object.assign({}, state, { validationCount }) - return Object.assign({}, state, { - validatedBlocks: [ - action.msg, - ...state.validatedBlocks - ], - validationCount - }) + return Object.assign({}, state, { validationCount }) } case 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH': { if (state.channelDisconnected || state.beyondPageOne) return state @@ -209,22 +200,6 @@ const elements = { $channelBatching.show() $el[0].innerHTML = numeral(state.internalTransactionsBatch.length).format() } - }, - '[data-selector="validations-list"]': { - load ($el) { - return { - validatedBlocks: $el.children().map((index, el) => ({ - blockNumber: parseInt(el.dataset.blockNumber), - blockHtml: el.outerHTML - })).toArray() - } - }, - render ($el, state, oldState) { - if (oldState.validatedBlocks === state.validatedBlocks) return - const container = $el[0] - const newElements = _.map(state.validatedBlocks, ({ blockHtml }) => $(blockHtml)[0]) - listMorph(container, newElements, { key: 'dataset.blockNumber' }) - } } } diff --git a/apps/block_scout_web/assets/js/pages/address/validations.js b/apps/block_scout_web/assets/js/pages/address/validations.js new file mode 100644 index 0000000000..6aca07a98b --- /dev/null +++ b/apps/block_scout_web/assets/js/pages/address/validations.js @@ -0,0 +1,64 @@ +import $ from 'jquery' +import _ from 'lodash' +import humps from 'humps' +import socket from '../../socket' +import { connectElements } from '../../lib/redux_helpers.js' +import { createAsyncLoadStore } from '../../lib/async_listing_load.js' + +export const initialState = { + addressHash: null, + channelDisconnected: false +} + +export function reducer (state = initialState, action) { + switch (action.type) { + case 'PAGE_LOAD': + case 'ELEMENTS_LOAD': { + return Object.assign({}, state, _.omit(action, 'type')) + } + case 'CHANNEL_DISCONNECTED': { + return Object.assign({}, state, { channelDisconnected: true }) + } + case 'RECEIVED_NEW_BLOCK': { + if (state.channelDisconnected) return state + if (state.beyondPageOne) return state + + return Object.assign({}, state, { + items: [ + action.blockHtml, + ...state.items + ] + }) + } + default: + return state + } +} + +const elements = { + '[data-selector="channel-disconnected-message"]': { + render ($el, state) { + if (state.channelDisconnected) $el.show() + } + } +} + +if ($('[data-page="blocks-validated"]').length) { + const store = createAsyncLoadStore(reducer, initialState, 'dataset.blockNumber') + connectElements({ store, elements }) + const addressHash = $('[data-page="address-details"]')[0].dataset.pageAddressHash + store.dispatch({ + type: 'PAGE_LOAD', + addressHash + }) + + const blocksChannel = socket.channel(`blocks:${addressHash}`, {}) + blocksChannel.join() + blocksChannel.onError(() => store.dispatch({ + type: 'CHANNEL_DISCONNECTED' + })) + blocksChannel.on('new_block', (msg) => store.dispatch({ + type: 'RECEIVED_NEW_BLOCK', + blockHtml: humps.camelizeKeys(msg).blockHtml + })) +} diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex index 6d39df12b5..90944822ab 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex @@ -5,12 +5,16 @@ defmodule BlockScoutWeb.AddressValidationController do use BlockScoutWeb, :controller import BlockScoutWeb.AddressController, only: [transaction_count: 1, validation_count: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] - alias Explorer.{Chain, Market} + import BlockScoutWeb.Chain, + only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + + alias BlockScoutWeb.BlockView alias Explorer.ExchangeRates.Token + alias Explorer.{Chain, Market} + alias Phoenix.View - def index(conn, %{"address_id" => address_hash_string} = params) do + def index(conn, %{"address_id" => address_hash_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, address} <- Chain.find_or_insert_address_from_hash(address_hash) do full_options = @@ -28,15 +32,52 @@ defmodule BlockScoutWeb.AddressValidationController do blocks_plus_one = Chain.get_blocks_validated_by_address(full_options, address) {blocks, next_page} = split_list_by_page(blocks_plus_one) + next_page_path = + case next_page_params(next_page, blocks, params) do + nil -> + nil + + next_page_params -> + address_validation_path( + conn, + :index, + address_hash_string, + Map.delete(next_page_params, "type") + ) + end + + items = + Enum.map(blocks, fn block -> + View.render_to_string( + BlockView, + "_tile.html", + conn: conn, + block: block, + block_type: BlockView.block_type(block) + ) + end) + + json(conn, %{items: items, next_page_path: next_page_path}) + else + :error -> + unprocessable_entity(conn) + + {:error, :not_found} -> + not_found(conn) + end + end + + def index(conn, %{"address_id" => address_hash_string}) do + with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + {:ok, address} <- Chain.find_or_insert_address_from_hash(address_hash) do render( conn, "index.html", address: address, - blocks: blocks, + current_path: current_path(conn), transaction_count: transaction_count(address), validation_count: validation_count(address), - exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null(), - next_page_params: next_page_params(next_page, blocks, params) + exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null() ) else :error -> 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 a9890e4293..9ed0c1f64e 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -1012,7 +1012,7 @@ defmodule BlockScoutWeb.Etherscan do @account_txlistinternal_action %{ name: "txlistinternal", description: - "Get internal transactions by transaction or address hash. Up to a maximum of 10,000 internal transactions.", + "Get internal transactions by transaction or address hash. Up to a maximum of 10,000 internal transactions. Also available through a GraphQL 'transaction' query.", required_params: [ %{ key: "txhash", diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex new file mode 100644 index 0000000000..08f3ca45ce --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/internal_transaction.ex @@ -0,0 +1,23 @@ +defmodule BlockScoutWeb.Resolvers.InternalTransaction do + @moduledoc false + + alias Absinthe.Relay.Connection + alias Explorer.Chain.Transaction + alias Explorer.{GraphQL, Repo} + + def get_by(%{transaction_hash: _, index: _} = args) do + GraphQL.get_internal_transaction(args) + end + + def get_by(%Transaction{} = transaction, args, _) do + transaction + |> GraphQL.transaction_to_internal_transactions_query() + |> Connection.from_query(&Repo.all/1, args, options(args)) + end + + defp options(%{before: _}), do: [] + + defp options(%{count: count}), do: [count: count] + + defp options(_), do: [] +end diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex index c13aa2a561..aa54a8e6da 100644 --- a/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/transaction.ex @@ -8,7 +8,7 @@ defmodule BlockScoutWeb.Resolvers.Transaction do def get_by(_, %{hash: hash}, _) do case Chain.hash_to_transaction(hash) do {:ok, transaction} -> {:ok, transaction} - {:error, :not_found} -> {:error, "Transaction hash #{hash} was not found."} + {:error, :not_found} -> {:error, "Transaction not found."} end end diff --git a/apps/block_scout_web/lib/block_scout_web/schema.ex b/apps/block_scout_web/lib/block_scout_web/schema.ex index c310ea844a..7d6d150acd 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema.ex @@ -6,8 +6,16 @@ defmodule BlockScoutWeb.Schema do alias Absinthe.Middleware.Dataloader, as: AbsintheMiddlewareDataloader alias Absinthe.Plugin, as: AbsinthePlugin - alias BlockScoutWeb.Resolvers.{Address, Block, Transaction} + + alias BlockScoutWeb.Resolvers.{ + Address, + Block, + InternalTransaction, + Transaction + } + alias Explorer.Chain + alias Explorer.Chain.InternalTransaction, as: ExplorerChainInternalTransaction alias Explorer.Chain.Transaction, as: ExplorerChainTransaction import_types(BlockScoutWeb.Schema.Types) @@ -17,6 +25,9 @@ defmodule BlockScoutWeb.Schema do %ExplorerChainTransaction{}, _ -> :transaction + %ExplorerChainInternalTransaction{}, _ -> + :internal_transaction + _, _ -> nil end) @@ -29,6 +40,11 @@ defmodule BlockScoutWeb.Schema do {:ok, hash} = Chain.string_to_transaction_hash(transaction_hash_string) Transaction.get_by(%{}, %{hash: hash}, %{}) + %{type: :internal_transaction, id: id}, _ -> + %{"transaction_hash" => transaction_hash_string, "index" => index} = Jason.decode!(id) + {:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string) + InternalTransaction.get_by(%{transaction_hash: transaction_hash, index: index}) + _, _ -> {:error, "Unknown node"} end) diff --git a/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex b/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex index 675992eea4..b7a8939a39 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex @@ -99,4 +99,18 @@ defmodule BlockScoutWeb.Schema.Scalars do value(:ok) value(:error) end + + enum :call_type do + value(:call) + value(:callcode) + value(:delegatecall) + value(:staticcall) + end + + enum :type do + value(:call) + value(:create) + value(:reward) + value(:selfdestruct) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/schema/types.ex index 3492c71fe9..85e843c447 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema/types.ex @@ -6,12 +6,16 @@ defmodule BlockScoutWeb.Schema.Types do import Absinthe.Resolution.Helpers - alias BlockScoutWeb.Resolvers.Transaction + alias BlockScoutWeb.Resolvers.{ + InternalTransaction, + Transaction + } import_types(Absinthe.Type.Custom) import_types(BlockScoutWeb.Schema.Scalars) connection(node_type: :transaction) + connection(node_type: :internal_transaction) @desc """ A stored representation of a Web3 address. @@ -60,6 +64,30 @@ defmodule BlockScoutWeb.Schema.Types do field(:parent_hash, :full_hash) end + @desc """ + Models internal transactions. + """ + node object(:internal_transaction, id_fetcher: &internal_transaction_id_fetcher/2) do + field(:call_type, :call_type) + field(:created_contract_code, :data) + field(:error, :string) + field(:gas, :decimal) + field(:gas_used, :decimal) + field(:index, :integer) + field(:init, :data) + field(:input, :data) + field(:output, :data) + field(:trace_address, :json) + field(:type, :type) + field(:value, :wei) + field(:block_number, :integer) + field(:transaction_index, :integer) + field(:created_contract_address_hash, :address_hash) + field(:from_address_hash, :address_hash) + field(:to_address_hash, :address_hash) + field(:transaction_hash, :full_hash) + end + @desc """ The representation of a verified Smart Contract. @@ -99,6 +127,19 @@ defmodule BlockScoutWeb.Schema.Types do field(:from_address_hash, :address_hash) field(:to_address_hash, :address_hash) field(:created_contract_address_hash, :address_hash) + + connection field(:internal_transactions, node_type: :internal_transaction) do + arg(:count, :integer) + resolve(&InternalTransaction.get_by/3) + + complexity(fn + %{first: first}, child_complexity -> + first * child_complexity + + %{last: last}, child_complexity -> + last * child_complexity + end) + end end @desc """ @@ -113,4 +154,8 @@ defmodule BlockScoutWeb.Schema.Types do end def transaction_id_fetcher(%{hash: hash}, _), do: to_string(hash) + + def internal_transaction_id_fetcher(%{transaction_hash: transaction_hash, index: index}, _) do + Jason.encode!(%{transaction_hash: to_string(transaction_hash), index: index}) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex index f9028ba3e6..f677724b5d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex @@ -1,7 +1,7 @@
<%= render BlockScoutWeb.AddressView, "overview.html", assigns %> -
+
@@ -98,7 +98,7 @@
-
+

<%=gettext("Blocks Validated")%>

- - <%= for block <- @blocks do %> - <%= render BlockScoutWeb.BlockView, "_tile.html", block: block, block_type: BlockScoutWeb.BlockView.block_type(block)%> - <% end %> - -
- <%= if @next_page_params do %> - <%= link( - gettext("Older"), - class: "button button-secondary button-sm float-right mt-3", - to: address_validation_path( - @conn, - :index, - @address, - @next_page_params - ) - ) %> - <% end %> +
+ + + + + <%= gettext("Loading...") %> +
+ + +
+ +
diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index d73265cc8d..0687a0e605 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -639,7 +639,7 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:76 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:31 -#: lib/block_scout_web/templates/address_validation/index.html.eex:117 +#: lib/block_scout_web/templates/address_validation/index.html.eex:126 #: lib/block_scout_web/templates/block/index.html.eex:30 #: lib/block_scout_web/templates/block_transaction/index.html.eex:50 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:41 @@ -1188,6 +1188,8 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_read_contract/index.html.eex:17 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:19 +#: lib/block_scout_web/templates/address_validation/index.html.eex:114 +#: lib/block_scout_web/templates/address_validation/index.html.eex:133 #: lib/block_scout_web/templates/tokens/read_contract/index.html.eex:25 msgid "Loading..." msgstr "" @@ -1417,7 +1419,13 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:60 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:26 +#: lib/block_scout_web/templates/address_validation/index.html.eex:121 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:23 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:21 msgid "Something went wrong, click to reload." msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/address_validation/index.html.eex:117 +msgid "There are no blocks validated by this address." +msgstr "" diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index 2b41acfd7f..f16f8735bb 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -639,7 +639,7 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:76 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:31 -#: lib/block_scout_web/templates/address_validation/index.html.eex:117 +#: lib/block_scout_web/templates/address_validation/index.html.eex:126 #: lib/block_scout_web/templates/block/index.html.eex:30 #: lib/block_scout_web/templates/block_transaction/index.html.eex:50 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:41 @@ -1188,6 +1188,8 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_read_contract/index.html.eex:17 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:19 +#: lib/block_scout_web/templates/address_validation/index.html.eex:114 +#: lib/block_scout_web/templates/address_validation/index.html.eex:133 #: lib/block_scout_web/templates/tokens/read_contract/index.html.eex:25 msgid "Loading..." msgstr "" @@ -1417,7 +1419,13 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:60 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:26 +#: lib/block_scout_web/templates/address_validation/index.html.eex:121 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:23 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:21 msgid "Something went wrong, click to reload." msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/address_validation/index.html.eex:117 +msgid "There are no blocks validated by this address." +msgstr "" diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs index 02dff2fe9a..471401f7b6 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs @@ -515,33 +515,13 @@ defmodule BlockScoutWeb.Schema.Query.AddressTest do |> List.last() |> Map.get("cursor") - query3 = """ - query ($hash: AddressHash!, $first: Int!, $after: Int!) { - address(hash: $hash) { - transactions(first: $first, after: $after) { - page_info { - has_next_page - has_previous_page - } - edges { - node { - hash - block_number - } - cursor - } - } - } - } - """ - variables3 = %{ "hash" => to_string(address.hash), "first" => 3, "after" => last_cursor_page2 } - conn = get(conn, "/graphql", query: query3, variables: variables3) + conn = get(conn, "/graphql", query: query2, variables: variables3) %{"data" => %{"address" => %{"transactions" => page3}}} = json_response(conn, 200) diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs index 6eac1cf81f..00d21fe85d 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/node_test.exs @@ -31,5 +31,100 @@ defmodule BlockScoutWeb.Schema.Query.NodeTest do } } end + + test "with 'id' for non-existent transaction", %{conn: conn} do + transaction = build(:transaction) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on Transaction { + id + hash + } + } + } + """ + + id = Base.encode64("Transaction:#{transaction.hash}") + + variables = %{"id" => id} + + conn = get(conn, "/graphql", query: query, variables: variables) + + %{"errors" => [error]} = json_response(conn, 200) + + assert error["message"] == "Transaction not found." + end + + test "with valid argument 'id' for an internal transaction", %{conn: conn} do + transaction = insert(:transaction) + + internal_transaction = insert(:internal_transaction, transaction: transaction, index: 0) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on InternalTransaction { + id + transaction_hash + index + } + } + } + """ + + id = + %{transaction_hash: to_string(transaction.hash), index: internal_transaction.index} + |> Jason.encode!() + |> (fn unique_id -> "InternalTransaction:#{unique_id}" end).() + |> Base.encode64() + + variables = %{"id" => id} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "node" => %{ + "id" => id, + "transaction_hash" => to_string(transaction.hash), + "index" => internal_transaction.index + } + } + } + end + + test "with 'id' for non-existent internal transaction", %{conn: conn} do + transaction = build(:transaction) + + internal_transaction = build(:internal_transaction, transaction: transaction, index: 0) + + query = """ + query($id: ID!) { + node(id: $id) { + ... on InternalTransaction { + id + transaction_hash + index + } + } + } + """ + + id = + %{transaction_hash: to_string(transaction.hash), index: internal_transaction.index} + |> Jason.encode!() + |> (fn unique_id -> "InternalTransaction:#{unique_id}" end).() + |> Base.encode64() + + variables = %{"id" => id} + + conn = get(conn, "/graphql", query: query, variables: variables) + + %{"errors" => [error]} = json_response(conn, 200) + + assert error["message"] == "Internal transaction not found." + end end end diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs index cda0bc56db..e76ca54e43 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/transaction_test.exs @@ -81,7 +81,7 @@ defmodule BlockScoutWeb.Schema.Query.TransactionTest do conn = get(conn, "/graphql", query: query, variables: variables) assert %{"errors" => [error]} = json_response(conn, 200) - assert error["message"] =~ ~s(Transaction hash #{transaction.hash} was not found) + assert error["message"] == "Transaction not found." end test "errors if argument 'hash' is missing", %{conn: conn} do @@ -116,4 +116,396 @@ defmodule BlockScoutWeb.Schema.Query.TransactionTest do assert error["message"] =~ ~s(Argument "hash" has invalid value) end end + + describe "transaction internal_transactions field" do + test "returns all expected internal_transaction fields", %{conn: conn} do + address = insert(:address) + contract_address = insert(:contract_address) + + block = insert(:block) + + transaction = + :transaction + |> insert(from_address: address) + |> with_contract_creation(contract_address) + |> with_block(block) + + internal_transaction_attributes = %{ + transaction: transaction, + index: 0, + from_address: address, + call_type: :call + } + + internal_transaction = + :internal_transaction_create + |> insert(internal_transaction_attributes) + |> with_contract_creation(contract_address) + + query = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + call_type + created_contract_code + error + gas + gas_used + index + init + input + output + trace_address + type + value + block_number + transaction_index + created_contract_address_hash + from_address_hash + to_address_hash + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "transaction" => %{ + "internal_transactions" => %{ + "edges" => [ + %{ + "node" => %{ + "call_type" => internal_transaction.call_type |> to_string() |> String.upcase(), + "created_contract_code" => to_string(internal_transaction.created_contract_code), + "error" => internal_transaction.error, + "gas" => to_string(internal_transaction.gas), + "gas_used" => to_string(internal_transaction.gas_used), + "index" => internal_transaction.index, + "init" => to_string(internal_transaction.init), + "input" => nil, + "output" => nil, + "trace_address" => Jason.encode!(internal_transaction.trace_address), + "type" => internal_transaction.type |> to_string() |> String.upcase(), + "value" => to_string(internal_transaction.value.value), + "block_number" => internal_transaction.block_number, + "transaction_index" => internal_transaction.transaction_index, + "created_contract_address_hash" => + to_string(internal_transaction.created_contract_address_hash), + "from_address_hash" => to_string(internal_transaction.from_address_hash), + "to_address_hash" => nil, + "transaction_hash" => to_string(internal_transaction.transaction_hash) + } + } + ] + } + } + } + } + end + + test "with transaction with zero internal transactions", %{conn: conn} do + address = insert(:address) + + block = insert(:block) + + transaction = + :transaction + |> insert(from_address: address) + |> with_block(block) + + query = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "first" => 1 + } + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "transaction" => %{ + "internal_transactions" => %{ + "edges" => [] + } + } + } + } + end + + test "internal transactions are ordered by ascending index", %{conn: conn} do + transaction = insert(:transaction) + + insert(:internal_transaction, transaction: transaction, index: 2) + insert(:internal_transaction, transaction: transaction, index: 0) + insert(:internal_transaction, transaction: transaction, index: 1) + + query = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "first" => 3 + } + + response = + conn + |> get("/graphql", query: query, variables: variables) + |> json_response(200) + + internal_transactions = get_in(response, ["data", "transaction", "internal_transactions", "edges"]) + + index_order = Enum.map(internal_transactions, & &1["node"]["index"]) + + assert index_order == Enum.sort(index_order) + end + + test "complexity correlates to first or last argument", %{conn: conn} do + transaction = insert(:transaction) + + query1 = """ + query ($hash: FullHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables1 = %{ + "hash" => to_string(transaction.hash), + "first" => 55 + } + + response1 = + conn + |> get("/graphql", query: query1, variables: variables1) + |> json_response(200) + + assert %{"errors" => [error1, error2, error3]} = response1 + assert error1["message"] =~ ~s(Field internal_transactions is too complex) + assert error2["message"] =~ ~s(Field transaction is too complex) + assert error3["message"] =~ ~s(Operation is too complex) + + query2 = """ + query ($hash: FullHash!, $last: Int!, $count: Int!) { + transaction(hash: $hash) { + internal_transactions(last: $last, count: $count) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables2 = %{ + "hash" => to_string(transaction.hash), + "last" => 55, + "count" => 100 + } + + response2 = + conn + |> get("/graphql", query: query2, variables: variables2) + |> json_response(200) + + assert %{"errors" => [error1, error2, error3]} = response2 + assert error1["message"] =~ ~s(Field internal_transactions is too complex) + assert error2["message"] =~ ~s(Field transaction is too complex) + assert error3["message"] =~ ~s(Operation is too complex) + end + + test "with 'last' and 'count' arguments", %{conn: conn} do + # "`last: N` must always be acompanied by either a `before:` argument to + # the query, or an explicit `count:` option to the `from_query` call. + # Otherwise it is impossible to derive the required offset." + # https://hexdocs.pm/absinthe_relay/Absinthe.Relay.Connection.html#from_query/4 + # + # This test ensures support for a 'count' argument. + + transaction = insert(:transaction) + + insert(:internal_transaction, transaction: transaction, index: 2) + insert(:internal_transaction, transaction: transaction, index: 0) + insert(:internal_transaction, transaction: transaction, index: 1) + + query = """ + query ($hash: FullHash!, $last: Int!, $count: Int!) { + transaction(hash: $hash) { + internal_transactions(last: $last, count: $count) { + edges { + node { + index + transaction_hash + } + } + } + } + } + """ + + variables = %{ + "hash" => to_string(transaction.hash), + "last" => 1, + "count" => 3 + } + + [internal_transaction] = + conn + |> get("/graphql", query: query, variables: variables) + |> json_response(200) + |> get_in(["data", "transaction", "internal_transactions", "edges"]) + + assert internal_transaction["node"]["index"] == 2 + end + + test "pagination support with 'first' and 'after' arguments", %{conn: conn} do + transaction = insert(:transaction) + + for index <- 0..5 do + insert(:internal_transaction_create, transaction: transaction, index: index) + end + + query1 = """ + query ($hash: AddressHash!, $first: Int!) { + transaction(hash: $hash) { + internal_transactions(first: $first) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + index + transaction_hash + } + cursor + } + } + } + } + """ + + variables1 = %{ + "hash" => to_string(transaction.hash), + "first" => 2 + } + + conn = get(conn, "/graphql", query: query1, variables: variables1) + + %{"data" => %{"transaction" => %{"internal_transactions" => page1}}} = json_response(conn, 200) + + assert page1["page_info"] == %{"has_next_page" => true, "has_previous_page" => false} + assert Enum.all?(page1["edges"], &(&1["node"]["index"] in 0..1)) + + last_cursor_page1 = + page1 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + query2 = """ + query ($hash: AddressHash!, $first: Int!, $after: ID!) { + transaction(hash: $hash) { + internal_transactions(first: $first, after: $after) { + page_info { + has_next_page + has_previous_page + } + edges { + node { + index + transaction_hash + } + cursor + } + } + } + } + """ + + variables2 = %{ + "hash" => to_string(transaction.hash), + "first" => 2, + "after" => last_cursor_page1 + } + + page2 = + conn + |> get("/graphql", query: query2, variables: variables2) + |> json_response(200) + |> get_in(["data", "transaction", "internal_transactions"]) + + assert page2["page_info"] == %{"has_next_page" => true, "has_previous_page" => true} + assert Enum.all?(page2["edges"], &(&1["node"]["index"] in 2..3)) + + last_cursor_page2 = + page2 + |> Map.get("edges") + |> List.last() + |> Map.get("cursor") + + variables3 = %{ + "hash" => to_string(transaction.hash), + "first" => 2, + "after" => last_cursor_page2 + } + + page3 = + conn + |> get("/graphql", query: query2, variables: variables3) + |> json_response(200) + |> get_in(["data", "transaction", "internal_transactions"]) + + assert page3["page_info"] == %{"has_next_page" => false, "has_previous_page" => true} + assert Enum.all?(page3["edges"], &(&1["node"]["index"] in 4..5)) + end + end end diff --git a/apps/explorer/lib/explorer/graphql.ex b/apps/explorer/lib/explorer/graphql.ex index 50d3161861..b3265a4150 100644 --- a/apps/explorer/lib/explorer/graphql.ex +++ b/apps/explorer/lib/explorer/graphql.ex @@ -5,13 +5,28 @@ defmodule Explorer.GraphQL do import Ecto.Query, only: [ + from: 2, order_by: 3, or_where: 3, where: 3 ] - alias Explorer.Chain.{Address, Hash, Transaction} + alias Explorer.Chain.{ + Address, + Hash, + InternalTransaction, + Transaction + } + + alias Explorer.{Chain, Repo} + + @doc """ + Returns a query to fetch transactions with a matching `to_address_hash`, + `from_address_hash`, or `created_contract_address_hash` field for a given address. + Orders transactions by descending block number and index. + """ + @spec address_to_transactions_query(Address.t()) :: Ecto.Query.t() def address_to_transactions_query(%Address{hash: %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash}) do Transaction |> order_by([transaction], desc: transaction.block_number, desc: transaction.index) @@ -19,4 +34,37 @@ defmodule Explorer.GraphQL do |> or_where([transaction], transaction.from_address_hash == ^address_hash) |> or_where([transaction], transaction.created_contract_address_hash == ^address_hash) end + + @doc """ + Returns an internal transaction for a given transaction hash and index. + """ + @spec get_internal_transaction(map()) :: {:ok, InternalTransaction.t()} | {:error, String.t()} + def get_internal_transaction(%{transaction_hash: _, index: _} = clauses) do + if internal_transaction = Repo.get_by(InternalTransaction, clauses) do + {:ok, internal_transaction} + else + {:error, "Internal transaction not found."} + end + end + + @doc """ + Returns a query to fetch internal transactions for a given transaction. + + Orders internal transactions by ascending index. + """ + @spec transaction_to_internal_transactions_query(Transaction.t()) :: Ecto.Query.t() + def transaction_to_internal_transactions_query(%Transaction{ + hash: %Hash{byte_count: unquote(Hash.Full.byte_count())} = hash + }) do + query = + from( + it in InternalTransaction, + inner_join: t in assoc(it, :transaction), + order_by: [asc: it.index], + where: it.transaction_hash == ^hash, + select: it + ) + + Chain.where_transaction_has_multiple_internal_transactions(query) + end end diff --git a/apps/explorer/test/explorer/graphql_test.exs b/apps/explorer/test/explorer/graphql_test.exs index 91cc1c6c00..f98a030bb2 100644 --- a/apps/explorer/test/explorer/graphql_test.exs +++ b/apps/explorer/test/explorer/graphql_test.exs @@ -87,4 +87,98 @@ defmodule Explorer.GraphQLTest do assert block_number_and_index_order == Enum.sort(block_number_and_index_order, &(&1 >= &2)) end end + + describe "get_internal_transaction/1" do + test "returns existing internal transaction" do + transaction = insert(:transaction) + + internal_transaction = insert(:internal_transaction, transaction: transaction, index: 0) + + clauses = %{transaction_hash: transaction.hash, index: internal_transaction.index} + + {:ok, found_internal_transaction} = GraphQL.get_internal_transaction(clauses) + + assert found_internal_transaction.transaction_hash == transaction.hash + assert found_internal_transaction.index == internal_transaction.index + end + + test "returns error tuple for non-existent internal transaction" do + transaction = build(:transaction) + + internal_transaction = build(:internal_transaction, transaction: transaction, index: 0) + + clauses = %{transaction_hash: transaction.hash, index: internal_transaction.index} + + assert GraphQL.get_internal_transaction(clauses) == {:error, "Internal transaction not found."} + end + end + + describe "transcation_to_internal_transactions_query/1" do + test "with transaction with one internal transaction" do + transaction1 = insert(:transaction) + transaction2 = insert(:transaction) + + internal_transaction = insert(:internal_transaction_create, transaction: transaction1, index: 0) + insert(:internal_transaction_create, transaction: transaction2, index: 0) + + [found_internal_transaction] = + transaction1 + |> GraphQL.transaction_to_internal_transactions_query() + |> Repo.all() + + assert found_internal_transaction.transaction_hash == transaction1.hash + assert found_internal_transaction.index == internal_transaction.index + end + + test "with transaction with multiple internal transactions" do + transaction1 = insert(:transaction) + transaction2 = insert(:transaction) + + for index <- 0..2 do + insert(:internal_transaction_create, transaction: transaction1, index: index) + end + + insert(:internal_transaction_create, transaction: transaction2, index: 0) + + found_internal_transactions = + transaction1 + |> GraphQL.transaction_to_internal_transactions_query() + |> Repo.all() + + assert length(found_internal_transactions) == 3 + + for found_internal_transaction <- found_internal_transactions do + assert found_internal_transaction.transaction_hash == transaction1.hash + end + end + + test "orders internal transactions by ascending index" do + transaction = insert(:transaction) + + insert(:internal_transaction_create, transaction: transaction, index: 2) + insert(:internal_transaction_create, transaction: transaction, index: 0) + insert(:internal_transaction_create, transaction: transaction, index: 1) + + found_internal_transactions = + transaction + |> GraphQL.transaction_to_internal_transactions_query() + |> Repo.all() + + index_order = Enum.map(found_internal_transactions, & &1.index) + + assert index_order == Enum.sort(index_order) + end + + # Note that `transaction_to_internal_transactions_query/1` relies on + # `Explorer.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