diff --git a/CHANGELOG.md b/CHANGELOG.md index 741a994f5d..f65c6c050e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- [#6694](https://github.com/blockscout/blockscout/pull/6694) - Add withdrawals support (EIP-4895) - [#7355](https://github.com/blockscout/blockscout/pull/7355) - Add endpoint for token info import - [#7393](https://github.com/blockscout/blockscout/pull/7393) - Realtime fetcher max gap diff --git a/apps/block_scout_web/assets/js/pages/address.js b/apps/block_scout_web/assets/js/pages/address.js index 53feee1d9f..9edee8b5cd 100644 --- a/apps/block_scout_web/assets/js/pages/address.js +++ b/apps/block_scout_web/assets/js/pages/address.js @@ -251,6 +251,7 @@ if ($addressDetailsPage.length) { const shouldScroll = pathParts.includes('transactions') || pathParts.includes('token-transfers') || pathParts.includes('tokens') || + pathParts.includes('withdrawals') || pathParts.includes('internal-transactions') || pathParts.includes('coin-balances') || pathParts.includes('logs') || diff --git a/apps/block_scout_web/lib/block_scout_web/api_router.ex b/apps/block_scout_web/lib/block_scout_web/api_router.ex index 7b45d1a070..4c04d7dd9d 100644 --- a/apps/block_scout_web/lib/block_scout_web/api_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/api_router.ex @@ -141,6 +141,7 @@ defmodule BlockScoutWeb.ApiRouter do get("/", V2.BlockController, :blocks) get("/:block_hash_or_number", V2.BlockController, :block) get("/:block_hash_or_number/transactions", V2.BlockController, :transactions) + get("/:block_hash_or_number/withdrawals", V2.BlockController, :withdrawals) end scope "/addresses" do @@ -156,6 +157,7 @@ defmodule BlockScoutWeb.ApiRouter do get("/:address_hash/blocks-validated", V2.AddressController, :blocks_validated) get("/:address_hash/coin-balance-history", V2.AddressController, :coin_balance_history) get("/:address_hash/coin-balance-history-by-day", V2.AddressController, :coin_balance_history_by_day) + get("/:address_hash/withdrawals", V2.AddressController, :withdrawals) end scope "/tokens" do @@ -186,6 +188,10 @@ defmodule BlockScoutWeb.ApiRouter do get("/market", V2.StatsController, :market_chart) end end + + scope "/withdrawals" do + get("/", V2.WithdrawalController, :withdrawals_list) + end end scope "/v1", as: :api_v1 do diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex index d4e30d03ba..cc83b040a7 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -30,7 +30,8 @@ defmodule BlockScoutWeb.Chain do Token.Instance, TokenTransfer, Transaction, - Wei + Wei, + Withdrawal } alias Explorer.PagingOptions @@ -437,6 +438,10 @@ defmodule BlockScoutWeb.Chain do %{"smart_contract_id" => smart_contract.id} end + defp paging_params(%Withdrawal{index: index}) do + %{"index" => index} + end + # clause for search results pagination defp paging_params(%{ address_hash: address_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex new file mode 100644 index 0000000000..7fcb05d5b4 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex @@ -0,0 +1,121 @@ +defmodule BlockScoutWeb.AddressWithdrawalController do + @moduledoc """ + Display all the withdrawals that terminate at this Address. + """ + + use BlockScoutWeb, :controller + + import BlockScoutWeb.Account.AuthController, only: [current_user: 1] + + import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + + import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] + + alias BlockScoutWeb.{AccessHelper, AddressWithdrawalView, Controller} + alias Explorer.{Chain, Market} + + alias Explorer.Chain.Wei + + alias Explorer.ExchangeRates.Token + alias Indexer.Fetcher.CoinBalanceOnDemand + alias Phoenix.View + + def index(conn, %{"address_id" => address_hash_string, "type" => "JSON"} = params) do + address_options = [necessity_by_association: %{:names => :optional, :smart_contract => :optional}] + + with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + {:ok, address} <- Chain.hash_to_address(address_hash, address_options, false), + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do + options = paging_options(params) + + withdrawals_plus_one = Chain.address_hash_to_withdrawals(address_hash, options) + {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) + + next_page_url = + case next_page_params(next_page, withdrawals, params) do + nil -> + nil + + next_page_params -> + address_withdrawal_path( + conn, + :index, + address, + Map.delete(next_page_params, "type") + ) + end + + items_json = + for withdrawal <- withdrawals do + View.render_to_string(AddressWithdrawalView, "_withdrawal.html", withdrawal: withdrawal) + end + + json(conn, %{items: items_json, next_page_path: next_page_url}) + else + :error -> + unprocessable_entity(conn) + + {:restricted_access, _} -> + not_found(conn) + + {:error, :not_found} -> + case Chain.Hash.Address.validate(address_hash_string) do + {:ok, _} -> + json(conn, %{items: [], next_page_path: ""}) + + _ -> + not_found(conn) + end + end + end + + def index(conn, %{"address_id" => address_hash_string} = params) do + with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + {:ok, address} <- Chain.hash_to_address(address_hash), + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do + render( + conn, + "index.html", + address: address, + coin_balance_status: CoinBalanceOnDemand.trigger_fetch(address), + exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null(), + counters_path: address_path(conn, :address_counters, %{"id" => address_hash_string}), + current_path: Controller.current_full_path(conn), + tags: get_address_tags(address_hash, current_user(conn)) + ) + else + :error -> + unprocessable_entity(conn) + + {:restricted_access, _} -> + not_found(conn) + + {:error, :not_found} -> + case Chain.Hash.Address.validate(address_hash_string) do + {:ok, _} -> + {:ok, address_hash} = Chain.string_to_address_hash(address_hash_string) + + address = %Chain.Address{ + hash: address_hash, + smart_contract: nil, + token: nil, + fetched_coin_balance: %Wei{value: Decimal.new(0)} + } + + render( + conn, + "index.html", + address: address, + coin_balance_status: nil, + exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null(), + counters_path: address_path(conn, :address_counters, %{"id" => address_hash_string}), + current_path: Controller.current_full_path(conn), + tags: get_address_tags(address_hash, current_user(conn)) + ) + + _ -> + not_found(conn) + end + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex index ab29da2107..9b999f9234 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex @@ -14,7 +14,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do only: [delete_parameters_from_next_page_params: 1, token_transfers_types_options: 1] alias BlockScoutWeb.AccessHelper - alias BlockScoutWeb.API.V2.{BlockView, TransactionView} + alias BlockScoutWeb.API.V2.{BlockView, TransactionView, WithdrawalView} alias Explorer.ExchangeRates.Token alias Explorer.{Chain, Market} alias Indexer.Fetcher.{CoinBalanceOnDemand, TokenBalanceOnDemand} @@ -370,6 +370,23 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + def withdrawals(conn, %{"address_hash" => address_hash_string} = params) do + with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), + {:not_found, {:ok, _address}} <- {:not_found, Chain.hash_to_address(address_hash, @api_true, false)} do + options = @api_true |> Keyword.merge(paging_options(params)) + withdrawals_plus_one = address_hash |> Chain.address_hash_to_withdrawals(options) + {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) + + next_page_params = next_page |> next_page_params(withdrawals, params) |> delete_parameters_from_next_page_params() + + conn + |> put_status(200) + |> put_view(WithdrawalView) + |> render(:withdrawals, %{withdrawals: withdrawals, next_page_params: next_page_params}) + end + end + def addresses_list(conn, params) do {addresses, next_page} = params diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex index 78f0dc47d3..11492b2420 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex @@ -12,7 +12,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1, select_block_type: 1] - alias BlockScoutWeb.API.V2.TransactionView + alias BlockScoutWeb.API.V2.{TransactionView, WithdrawalView} alias Explorer.Chain @transaction_necessity_by_association [ @@ -106,4 +106,23 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> render(:transactions, %{transactions: transactions, next_page_params: next_page_params}) end end + + def withdrawals(conn, %{"block_hash_or_number" => block_hash_or_number} = params) do + with {:ok, type, value} <- parse_block_hash_or_number_param(block_hash_or_number), + {:ok, block} <- fetch_block(type, value, @api_true) do + full_options = + [necessity_by_association: %{address: :optional}, api?: true] + |> Keyword.merge(paging_options(params)) + + withdrawals_plus_one = Chain.block_to_withdrawals(block.hash, full_options) + {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) + + next_page_params = next_page |> next_page_params(withdrawals, params) |> delete_parameters_from_next_page_params() + + conn + |> put_status(200) + |> put_view(WithdrawalView) + |> render(:withdrawals, %{withdrawals: withdrawals, next_page_params: next_page_params}) + end + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex new file mode 100644 index 0000000000..983a0e18cc --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex @@ -0,0 +1,25 @@ +defmodule BlockScoutWeb.API.V2.WithdrawalController do + use BlockScoutWeb, :controller + + import BlockScoutWeb.Chain, + only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + + import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] + + alias Explorer.Chain + + def withdrawals_list(conn, params) do + full_options = + [necessity_by_association: %{address: :optional, block: :optional}, api?: true] + |> Keyword.merge(paging_options(params)) + + withdrawals_plus_one = Chain.list_withdrawals(full_options) + {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) + + next_page_params = next_page |> next_page_params(withdrawals, params) |> delete_parameters_from_next_page_params() + + conn + |> put_status(200) + |> render(:withdrawals, %{withdrawals: withdrawals, next_page_params: next_page_params}) + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex index dcd28110dd..74ef3f6b2f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex @@ -139,7 +139,7 @@ defmodule BlockScoutWeb.BlockTransactionController do end end - defp param_block_hash_or_number_to_block(param, options) do + def param_block_hash_or_number_to_block(param, options) do case parse_block_hash_or_number_param(param) do {:ok, :number, number} -> number_to_block(number, options) @@ -152,9 +152,9 @@ defmodule BlockScoutWeb.BlockTransactionController do end end - defp block_above_tip("0x" <> _), do: {:error, :hash} + def block_above_tip("0x" <> _), do: {:error, :hash} - defp block_above_tip(block_hash_or_number) when is_binary(block_hash_or_number) do + def block_above_tip(block_hash_or_number) when is_binary(block_hash_or_number) do case Chain.max_consensus_block_number() do {:ok, max_consensus_block_number} -> {block_number, _} = Integer.parse(block_hash_or_number) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex new file mode 100644 index 0000000000..c6044d84ca --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex @@ -0,0 +1,106 @@ +defmodule BlockScoutWeb.BlockWithdrawalController do + use BlockScoutWeb, :controller + + import BlockScoutWeb.Chain, + only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + + import BlockScoutWeb.BlockTransactionController, only: [param_block_hash_or_number_to_block: 2, block_above_tip: 1] + + alias BlockScoutWeb.{BlockTransactionView, BlockWithdrawalView, Controller} + alias Explorer.Chain + alias Phoenix.View + + def index(conn, %{"block_hash_or_number" => formatted_block_hash_or_number, "type" => "JSON"} = params) do + case param_block_hash_or_number_to_block(formatted_block_hash_or_number, []) do + {:ok, block} -> + full_options = + [necessity_by_association: %{address: :optional}] + |> Keyword.merge(paging_options(params)) + + withdrawals_plus_one = Chain.block_to_withdrawals(block.hash, full_options) + + {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) + + next_page_path = + case next_page_params(next_page, withdrawals, params) do + nil -> + nil + + next_page_params -> + block_withdrawal_path( + conn, + :index, + block, + Map.delete(next_page_params, "type") + ) + end + + items = + for withdrawal <- withdrawals do + View.render_to_string(BlockWithdrawalView, "_withdrawal.html", withdrawal: withdrawal) + end + + json( + conn, + %{ + items: items, + next_page_path: next_page_path + } + ) + + {:error, {:invalid, :hash}} -> + not_found(conn) + + {:error, {:invalid, :number}} -> + not_found(conn) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> render( + BlockTransactionView, + "404.html", + block: nil, + block_above_tip: block_above_tip(formatted_block_hash_or_number) + ) + end + end + + def index(conn, %{"block_hash_or_number" => formatted_block_hash_or_number}) do + case param_block_hash_or_number_to_block(formatted_block_hash_or_number, + necessity_by_association: %{ + [miner: :names] => :required, + :uncles => :optional, + :nephews => :optional, + :rewards => :optional + } + ) do + {:ok, block} -> + block_transaction_count = Chain.block_to_transaction_count(block.hash) + + render( + conn, + "index.html", + block: block, + block_transaction_count: block_transaction_count, + current_path: Controller.current_full_path(conn) + ) + + {:error, {:invalid, :hash}} -> + not_found(conn) + + {:error, {:invalid, :number}} -> + not_found(conn) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> render( + BlockTransactionView, + "404.html", + block: nil, + block_above_tip: block_above_tip(formatted_block_hash_or_number) + ) + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex new file mode 100644 index 0000000000..e3405c69eb --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex @@ -0,0 +1,39 @@ +defmodule BlockScoutWeb.WithdrawalController do + use BlockScoutWeb, :controller + + import BlockScoutWeb.Chain, + only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1, fetch_page_number: 1] + + alias BlockScoutWeb.{Controller, WithdrawalView} + alias Explorer.Chain + alias Phoenix.View + + def index(conn, %{"type" => "JSON"} = params) do + full_options = + [necessity_by_association: %{address: :optional, block: :optional}] + |> Keyword.merge(paging_options(params)) + + withdrawals_plus_one = Chain.list_withdrawals(full_options) + {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) + + items = + for withdrawal <- withdrawals do + View.render_to_string(WithdrawalView, "_withdrawal.html", withdrawal: withdrawal) + end + + next_page_path = + case next_page_params(next_page, withdrawals, params) do + nil -> nil + next_page_params -> withdrawal_path(conn, :index, Map.delete(next_page_params, "type")) + end + + json(conn, %{items: items, next_page_path: next_page_path}) + end + + def index(conn, params) do + render(conn, "index.html", + current_path: Controller.current_full_path(conn), + page_number: params |> fetch_page_number() |> Integer.to_string() + ) + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/_tabs.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/_tabs.html.eex index 2a29f0de4c..de29c7e7fd 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/_tabs.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/_tabs.html.eex @@ -24,6 +24,14 @@ "data-test": "tokens_tab_link" ) %> <% end %> + <%= if Chain.check_if_withdrawals_at_address(@address.hash) do %> + <%= link( + gettext("Withdrawals"), + class: "card-tab #{tab_status("withdrawals", @conn.request_path)}", + to: AccessHelper.get_path(@conn, :address_withdrawal_path, :index, @address.hash), + "data-test": "withdrawals_tab_link" + ) %> + <% end %> <%= link( gettext("Internal Transactions"), class: "card-tab #{tab_status("internal-transactions", @conn.request_path)}", diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_metatags.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_metatags.html.eex new file mode 100644 index 0000000000..3ef2a67ca2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_metatags.html.eex @@ -0,0 +1 @@ +<%= render BlockScoutWeb.AddressView, "_metatags.html", conn: @conn, address: @address %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_withdrawal.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_withdrawal.html.eex new file mode 100644 index 0000000000..1bae3593d8 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_withdrawal.html.eex @@ -0,0 +1,25 @@ + + + + <%= @withdrawal.index %> + + + + <%= @withdrawal.validator_index %> + + + + <%= render BlockScoutWeb.BlockView, + "_number_link.html", + block: @withdrawal.block + %> + + + + + + + + <%= format_wei_value(@withdrawal.amount, :ether) %> + + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/index.html.eex new file mode 100644 index 0000000000..d07c664e6b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/index.html.eex @@ -0,0 +1,66 @@ +
+ + <% is_proxy = BlockScoutWeb.AddressView.smart_contract_is_proxy?(@address) %> + + <%= render BlockScoutWeb.AddressView, "overview.html", address: @address, is_proxy: is_proxy, conn: @conn, exchange_rate: @exchange_rate, coin_balance_status: @coin_balance_status, counters_path: @counters_path, tags: @tags %> + +
+
+ <%= render BlockScoutWeb.AddressView, "_tabs.html", address: @address, is_proxy: is_proxy, conn: @conn %> +
+
+ +

<%= gettext "Withdrawals" %>

+
+ <%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> +
+
+ + + +
+
+ + + + + + + + + + + + <%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 5 %> + +
+
<%= gettext "Index" %>
+
+
<%= gettext "Validator index" %>
+
+
<%= gettext "Block" %>
+
+
<%= gettext "Age" %>
+
+
<%= gettext "Amount" %>
+
+
+
+ +
+
+ <%= gettext "There are no withdrawals for this address." %> +
+
+ +
+ <%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> +
+ +
+
+ +
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/block/_number_link.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/block/_number_link.html.eex new file mode 100644 index 0000000000..6046e0eddf --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/block/_number_link.html.eex @@ -0,0 +1,4 @@ +<%= link( + to_string(@block.number), + to: block_path(BlockScoutWeb.Endpoint, :show, @block) +) %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/block/_tabs.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/block/_tabs.html.eex new file mode 100644 index 0000000000..885aa9b98a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/block/_tabs.html.eex @@ -0,0 +1,19 @@ +
+ <%= + link( + gettext("Transactions"), + class: "card-tab #{tab_status("transactions", @conn.request_path)}", + to: block_transaction_path(@conn, :index, @conn.params["block_hash_or_number"]) + ) + %> + + <%= if Chain.check_if_withdrawals_in_block(@block.hash) do %> + <%= + link( + gettext("Withdrawals"), + class: "card-tab #{tab_status("withdrawals", @conn.request_path)}", + to: block_withdrawal_path(@conn, :index, @conn.params["block_hash_or_number"]) + ) + %> + <% end %> +
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/block_transaction/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/block_transaction/index.html.eex index c3f9ab4e25..d4c91eff4a 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/block_transaction/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/block_transaction/index.html.eex @@ -4,16 +4,8 @@
-
- <%= - link( - gettext("Transactions"), - class: "card-tab #{tab_status("transactions", @conn.request_path)}", - to: block_transaction_path(@conn, :index, @conn.params["block_hash_or_number"]) - ) - %> -
- + <%= render BlockScoutWeb.BlockView, "_tabs.html", assigns %> +
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_metatags.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_metatags.html.eex new file mode 100644 index 0000000000..bff7f9460c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_metatags.html.eex @@ -0,0 +1 @@ +<%= render BlockScoutWeb.BlockView, "_metatags.html", conn: @conn, block: @block %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_withdrawal.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_withdrawal.html.eex new file mode 100644 index 0000000000..9506edc331 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_withdrawal.html.eex @@ -0,0 +1,23 @@ + + + + <%= @withdrawal.index %> + + + + <%= @withdrawal.validator_index %> + + + + <%= render BlockScoutWeb.AddressView, + "_link.html", + address: @withdrawal.address, + contract: BlockScoutWeb.AddressView.contract?(@withdrawal.address), + use_custom_tooltip: false + %> + + + + <%= format_wei_value(@withdrawal.amount, :ether) %> + + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/index.html.eex new file mode 100644 index 0000000000..43c4515848 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/index.html.eex @@ -0,0 +1,54 @@ +
+ + <%= render BlockScoutWeb.BlockView, "overview.html", assigns %> + +
+
+ <%= render BlockScoutWeb.BlockView, "_tabs.html", assigns %> + +
+ + <%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> + + + +
+
+ + + + + + + + + + + <%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 4 %> + +
+
<%= gettext "Index" %>
+
+
<%= gettext "Validator index" %>
+
+
<%= gettext "To" %>
+
+
<%= gettext "Amount" %>
+
+
+
+ +
+
+ <%= gettext "There are no withdrawals for this block." %> +
+
+ + <%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", cur_page_number: "1", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> + +
+ +
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex index 2d8c4feb42..720184dc2b 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex @@ -67,6 +67,9 @@ <% end %> <%= link(gettext("Verified contracts"), to: verified_contracts_path(@conn, :index), + class: "dropdown-item border-bottom #{tab_status("reorgs", @conn.request_path)}")%> + <%= link(gettext("Withdrawals"), + to: withdrawal_path(@conn, :index), class: "dropdown-item #{tab_status("reorgs", @conn.request_path)}")%>
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex index 7e561dfe2f..81725695df 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex @@ -103,6 +103,7 @@ @view_module != Elixir.BlockScoutWeb.ChainView && @view_module != Elixir.BlockScoutWeb.BlockView && @view_module != Elixir.BlockScoutWeb.BlockTransactionView && + @view_module != Elixir.BlockScoutWeb.BlockWithdrawalView && @view_module != Elixir.BlockScoutWeb.AddressView && @view_module != Elixir.BlockScoutWeb.TokensView && @view_module != Elixir.BlockScoutWeb.TransactionView && @@ -115,6 +116,7 @@ @view_module != Elixir.BlockScoutWeb.AddressTransactionView && @view_module != Elixir.BlockScoutWeb.AddressTokenTransferView && @view_module != Elixir.BlockScoutWeb.AddressTokenView && + @view_module != Elixir.BlockScoutWeb.AddressWithdrawalView && @view_module != Elixir.BlockScoutWeb.AddressInternalTransactionView && @view_module != Elixir.BlockScoutWeb.AddressCoinBalanceView && @view_module != Elixir.BlockScoutWeb.AddressLogsView && @@ -142,7 +144,8 @@ @view_module != Elixir.BlockScoutWeb.SearchView && @view_module != Elixir.BlockScoutWeb.AddressContractVerificationViaStandardJsonInputView && @view_module != Elixir.BlockScoutWeb.AddressContractVerificationViaMultiPartFilesView && - @view_module != Elixir.BlockScoutWeb.StakesView + @view_module != Elixir.BlockScoutWeb.StakesView && + @view_module != Elixir.BlockScoutWeb.WithdrawalView ) do %> <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_metatags.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_metatags.html.eex new file mode 100644 index 0000000000..b17983990b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_metatags.html.eex @@ -0,0 +1,8 @@ + + <%= gettext( + "Beacon chain withdrawals - %{subnetwork} Explorer", + subnetwork: BlockScoutWeb.LayoutView.subnetwork_title() + ) %> + +"> +"> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_withdrawal.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_withdrawal.html.eex new file mode 100644 index 0000000000..e90e6c99d2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_withdrawal.html.eex @@ -0,0 +1,34 @@ + + + + <%= @withdrawal.index %> + + + + <%= @withdrawal.validator_index %> + + + + <%= render BlockScoutWeb.BlockView, + "_number_link.html", + block: @withdrawal.block + %> + + + + <%= render BlockScoutWeb.AddressView, + "_link.html", + address: @withdrawal.address, + contract: BlockScoutWeb.AddressView.contract?(@withdrawal.address), + use_custom_tooltip: false + %> + + + + + + + + <%= format_wei_value(@withdrawal.amount, :ether) %> + + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/index.html.eex new file mode 100644 index 0000000000..9b075854aa --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/withdrawal/index.html.eex @@ -0,0 +1,59 @@ +
+ <%= render BlockScoutWeb.Advertisement.TextAdView, "index.html", conn: @conn %> +
+
+

<%= gettext "Withdrawals" %>

+ +
+ <%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", cur_page_number: @page_number, show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> +
+ + + +
+
+ + + + + + + + + + + + + <%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 6 %> + +
+
<%= gettext "Index" %>
+
+
<%= gettext "Validator index" %>
+
+
<%= gettext "Block" %>
+
+
<%= gettext "To" %>
+
+
<%= gettext "Age" %>
+
+
<%= gettext "Amount" %>
+
+
+
+ +
+
+
+ + <%= gettext "There are no withdrawals." %> + +
+
+ + <%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", cur_page_number: @page_number, show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> +
+ +
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex new file mode 100644 index 0000000000..9ff659e847 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex @@ -0,0 +1,3 @@ +defmodule BlockScoutWeb.AddressWithdrawalView do + use BlockScoutWeb, :view +end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex index 49fe37b23c..4ebb5e53ca 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex @@ -107,7 +107,8 @@ defmodule BlockScoutWeb.API.V2.AddressView do "has_logs" => Chain.check_if_logs_at_address(address.hash, @api_true), "has_tokens" => Chain.check_if_tokens_at_address(address.hash, @api_true), "has_token_transfers" => Chain.check_if_token_transfers_at_address(address.hash, @api_true), - "watchlist_address_id" => Chain.select_watchlist_address_id(get_watchlist_id(conn), address.hash) + "watchlist_address_id" => Chain.select_watchlist_address_id(get_watchlist_id(conn), address.hash), + "has_beacon_chain_withdrawals" => Chain.check_if_withdrawals_at_address(address.hash, @api_true) }) end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex index cc2067913a..3a9e914400 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex @@ -7,6 +7,8 @@ defmodule BlockScoutWeb.API.V2.BlockView do alias Explorer.Chain.Block alias Explorer.Counters.BlockPriorityFeeCounter + @api_true [api?: true] + def render("message.json", assigns) do ApiView.render("message.json", assigns) end @@ -58,7 +60,9 @@ defmodule BlockScoutWeb.API.V2.BlockView do "gas_used_percentage" => gas_used_percentage(block), "burnt_fees_percentage" => burnt_fees_percentage(burned_fee, tx_fees), "type" => block |> BlockView.block_type() |> String.downcase(), - "tx_fees" => tx_fees + "tx_fees" => tx_fees, + "has_beacon_chain_withdrawals" => + if(single_block?, do: Chain.check_if_withdrawals_in_block(block.hash, @api_true), else: nil) } end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex new file mode 100644 index 0000000000..299d462972 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex @@ -0,0 +1,41 @@ +defmodule BlockScoutWeb.API.V2.WithdrawalView do + use BlockScoutWeb, :view + + alias BlockScoutWeb.API.V2.Helper + alias Explorer.Chain.Withdrawal + + def render("withdrawals.json", %{withdrawals: withdrawals, next_page_params: next_page_params}) do + %{"items" => Enum.map(withdrawals, &prepare_withdrawal(&1)), "next_page_params" => next_page_params} + end + + @spec prepare_withdrawal(Withdrawal.t()) :: map() + def prepare_withdrawal(%Withdrawal{block: %Ecto.Association.NotLoaded{}} = withdrawal) do + %{ + "index" => withdrawal.index, + "validator_index" => withdrawal.validator_index, + "receiver" => Helper.address_with_info(withdrawal.address, withdrawal.address_hash), + "amount" => withdrawal.amount + } + end + + def prepare_withdrawal(%Withdrawal{address: %Ecto.Association.NotLoaded{}} = withdrawal) do + %{ + "index" => withdrawal.index, + "validator_index" => withdrawal.validator_index, + "block_number" => withdrawal.block.number, + "amount" => withdrawal.amount, + "timestamp" => withdrawal.block.timestamp + } + end + + def prepare_withdrawal(%Withdrawal{} = withdrawal) do + %{ + "index" => withdrawal.index, + "validator_index" => withdrawal.validator_index, + "block_number" => withdrawal.block.number, + "receiver" => Helper.address_with_info(withdrawal.address, withdrawal.address_hash), + "amount" => withdrawal.amount, + "timestamp" => withdrawal.block.timestamp + } + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex new file mode 100644 index 0000000000..5fa812f198 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex @@ -0,0 +1,3 @@ +defmodule BlockScoutWeb.BlockWithdrawalView do + use BlockScoutWeb, :view +end diff --git a/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex new file mode 100644 index 0000000000..bbe7be4fcf --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex @@ -0,0 +1,3 @@ +defmodule BlockScoutWeb.WithdrawalView do + use BlockScoutWeb, :view +end diff --git a/apps/block_scout_web/lib/block_scout_web/web_router.ex b/apps/block_scout_web/lib/block_scout_web/web_router.ex index 20a92d8682..4b3f7a20ae 100644 --- a/apps/block_scout_web/lib/block_scout_web/web_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/web_router.ex @@ -104,6 +104,7 @@ defmodule BlockScoutWeb.WebRouter do resources "/block", BlockController, only: [:show], param: "hash_or_number" do resources("/transactions", BlockTransactionController, only: [:index], as: :transaction) + resources("/withdrawals", BlockWithdrawalController, only: [:index], as: :withdrawal) end resources("/blocks", BlockController, as: :blocks, only: [:index]) @@ -113,6 +114,7 @@ defmodule BlockScoutWeb.WebRouter do only: [:show], param: "hash_or_number" do resources("/transactions", BlockTransactionController, only: [:index], as: :transaction) + resources("/withdrawals", BlockWithdrawalController, only: [:index], as: :withdrawal) end get("/reorgs", BlockController, :reorg, as: :reorg) @@ -125,6 +127,8 @@ defmodule BlockScoutWeb.WebRouter do resources("/verified-contracts", VerifiedContractsController, only: [:index]) + resources("/withdrawals", WithdrawalController, only: [:index]) + get("/txs", TransactionController, :index) resources "/tx", TransactionController, only: [:show] do @@ -274,6 +278,13 @@ defmodule BlockScoutWeb.WebRouter do as: :token_transfers ) + resources( + "/withdrawals", + AddressWithdrawalController, + only: [:index], + as: :withdrawal + ) + resources("/tokens", AddressTokenController, only: [:index], as: :token) do resources( "/token-transfers", diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs new file mode 100644 index 0000000000..7a259f62e4 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs @@ -0,0 +1,123 @@ +defmodule BlockScoutWeb.AddressWithdrawalControllerTest do + use BlockScoutWeb.ConnCase, async: true + use ExUnit.Case, async: false + + import BlockScoutWeb.WebRouter.Helpers, only: [address_withdrawal_path: 3, address_withdrawal_path: 4] + import BlockScoutWeb.WeiHelper, only: [format_wei_value: 2] + import Mox + + alias Explorer.Chain.{Address, Transaction} + alias Explorer.ExchangeRates.Token + + setup :verify_on_exit! + + describe "GET index/2" do + setup :set_mox_global + + setup do + configuration = Application.get_env(:explorer, :checksum_function) + Application.put_env(:explorer, :checksum_function, :eth) + + on_exit(fn -> + Application.put_env(:explorer, :checksum_function, configuration) + end) + end + + test "with invalid address hash", %{conn: conn} do + conn = get(conn, address_withdrawal_path(conn, :index, "invalid_address")) + + assert html_response(conn, 422) + end + + test "with valid address hash without address in the DB", %{conn: conn} do + conn = + get( + conn, + address_withdrawal_path(conn, :index, Address.checksum("0x8bf38d4764929064f2d4d3a56520a76ab3df415b"), %{ + "type" => "JSON" + }) + ) + + assert json_response(conn, 200) + tiles = json_response(conn, 200)["items"] + assert tiles |> length() == 0 + end + + test "returns withdrawals for the address", %{conn: conn} do + address = insert(:address, withdrawals: insert_list(30, :withdrawal)) + + # to check that we can correctly render adress overview + get(conn, address_withdrawal_path(conn, :index, Address.checksum(address))) + + conn = get(conn, address_withdrawal_path(conn, :index, Address.checksum(address), %{"type" => "JSON"})) + + tiles = json_response(conn, 200)["items"] + indexes = Enum.map(address.withdrawals, &to_string(&1.index)) + + assert Enum.all?(indexes, fn index -> + Enum.any?(tiles, &String.contains?(&1, index)) + end) + end + + test "includes USD exchange rate value for address in assigns", %{conn: conn} do + address = insert(:address) + + conn = get(conn, address_withdrawal_path(BlockScoutWeb.Endpoint, :index, Address.checksum(address.hash))) + + assert %Token{} = conn.assigns.exchange_rate + end + + test "returns next page of results based on last seen withdrawal", %{conn: conn} do + address = insert(:address, withdrawals: insert_list(60, :withdrawal)) + + {first_page, second_page} = + address.withdrawals + |> Enum.sort(&(&1.index >= &2.index)) + |> Enum.split(51) + + conn = + get(conn, address_withdrawal_path(BlockScoutWeb.Endpoint, :index, Address.checksum(address.hash)), %{ + "index" => first_page |> List.last() |> (& &1.index).() |> Integer.to_string(), + "type" => "JSON" + }) + + tiles = json_response(conn, 200)["items"] + + assert Enum.all?(second_page, fn withdrawal -> + Enum.any?(tiles, fn tile -> + # more strict check since simple index could occur in the tile accidentally + String.contains?(tile, to_string(withdrawal.index)) and + String.contains?(tile, to_string(withdrawal.validator_index)) and + String.contains?(tile, to_string(withdrawal.block.number)) and + String.contains?(tile, format_wei_value(withdrawal.amount, :ether)) + end) + end) + + refute Enum.any?(first_page, fn withdrawal -> + Enum.any?(tiles, fn tile -> + # more strict check since simple index could occur in the tile accidentally + String.contains?(tile, to_string(withdrawal.index)) and + String.contains?(tile, to_string(withdrawal.validator_index)) and + String.contains?(tile, to_string(withdrawal.block.number)) and + String.contains?(tile, format_wei_value(withdrawal.amount, :ether)) + end) + end) + end + + test "next_page_params exist if not on last page", %{conn: conn} do + address = insert(:address, withdrawals: insert_list(51, :withdrawal)) + + conn = get(conn, address_withdrawal_path(conn, :index, Address.checksum(address.hash), %{"type" => "JSON"})) + + assert json_response(conn, 200)["next_page_path"] + end + + test "next_page_params are empty if on last page", %{conn: conn} do + address = insert(:address, withdrawals: insert_list(1, :withdrawal)) + + conn = get(conn, address_withdrawal_path(conn, :index, Address.checksum(address.hash), %{"type" => "JSON"})) + + refute json_response(conn, 200)["next_page_path"] + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs index 69f393a183..b414a917bc 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs @@ -12,7 +12,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do Log, Token, TokenTransfer, - Transaction + Transaction, + Withdrawal } alias Explorer.Account.WatchlistAddress @@ -65,7 +66,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "has_logs" => false, "has_tokens" => false, "has_token_transfers" => false, - "watchlist_address_id" => nil + "watchlist_address_id" => nil, + "has_beacon_chain_withdrawals" => false } request = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}") @@ -1590,6 +1592,35 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end end + describe "/addresses/{address_hash}/withdrawals" do + test "get empty list on non existing address", %{conn: conn} do + address = build(:address) + + request = get(conn, "/api/v2/addresses/#{address.hash}/withdrawals") + + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "get 422 on invalid address", %{conn: conn} do + request = get(conn, "/api/v2/addresses/0x/withdrawals") + + assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + end + + test "get withdrawals", %{conn: conn} do + address = insert(:address, withdrawals: insert_list(51, :withdrawal)) + + request = get(conn, "/api/v2/addresses/#{address.hash}/withdrawals") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/addresses/#{address.hash}/withdrawals", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, address.withdrawals) + end + end + describe "/addresses" do test "get empty list", %{conn: conn} do request = get(conn, "/api/v2/addresses") @@ -1699,6 +1730,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert to_string(log.transaction_hash) == json["tx_hash"] end + defp compare_item(%Withdrawal{} = withdrawal, json) do + assert withdrawal.index == json["index"] + end + defp check_paginated_response(first_page_resp, second_page_resp, list) do assert Enum.count(first_page_resp["items"]) == 50 assert first_page_resp["next_page_params"] != nil diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs index f4bff6e168..9e115db4bd 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs @@ -1,7 +1,7 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do use BlockScoutWeb.ConnCase - alias Explorer.Chain.{Address, Block, Transaction} + alias Explorer.Chain.{Address, Block, Transaction, Withdrawal} setup do Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) @@ -311,6 +311,77 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do end end + describe "/blocks/{block_hash_or_number}/withdrawals" do + test "return 422 on invalid parameter", %{conn: conn} do + request_1 = get(conn, "/api/v2/blocks/0x123123/withdrawals") + assert %{"message" => "Invalid hash"} = json_response(request_1, 422) + + request_2 = get(conn, "/api/v2/blocks/123qwe/withdrawals") + assert %{"message" => "Invalid number"} = json_response(request_2, 422) + end + + test "return 404 on non existing block", %{conn: conn} do + block = build(:block) + + request_1 = get(conn, "/api/v2/blocks/#{block.number}/withdrawals") + assert %{"message" => "Not found"} = json_response(request_1, 404) + + request_2 = get(conn, "/api/v2/blocks/#{block.hash}/withdrawals") + assert %{"message" => "Not found"} = json_response(request_2, 404) + end + + test "get empty list", %{conn: conn} do + block = insert(:block) + + request = get(conn, "/api/v2/blocks/#{block.number}/withdrawals") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + + request = get(conn, "/api/v2/blocks/#{block.hash}/withdrawals") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "get withdrawals", %{conn: conn} do + block = insert(:block, withdrawals: insert_list(3, :withdrawal)) + + [withdrawal | _] = Enum.reverse(block.withdrawals) + + request = get(conn, "/api/v2/blocks/#{block.number}/withdrawals") + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 3 + assert response["next_page_params"] == nil + compare_item(withdrawal, Enum.at(response["items"], 0)) + + request = get(conn, "/api/v2/blocks/#{block.hash}/withdrawals") + assert response_1 = json_response(request, 200) + assert response_1 == response + end + + test "get withdrawals with working next_page_params", %{conn: conn} do + block = insert(:block, withdrawals: insert_list(51, :withdrawal)) + + request = get(conn, "/api/v2/blocks/#{block.number}/withdrawals") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/blocks/#{block.number}/withdrawals", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, block.withdrawals) + + request_1 = get(conn, "/api/v2/blocks/#{block.hash}/withdrawals") + assert response_1 = json_response(request_1, 200) + + assert response_1 == response + + request_2 = get(conn, "/api/v2/blocks/#{block.hash}/withdrawals", response_1["next_page_params"]) + assert response_2 = json_response(request_2, 200) + assert response_2 == response_2nd_page + end + end + defp compare_item(%Block{} = block, json) do assert to_string(block.hash) == json["hash"] assert block.number == json["height"] @@ -324,6 +395,10 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do assert Address.checksum(transaction.to_address_hash) == json["to"]["hash"] end + defp compare_item(%Withdrawal{} = withdrawal, json) do + assert withdrawal.index == json["index"] + end + defp check_paginated_response(first_page_resp, second_page_resp, list) do assert Enum.count(first_page_resp["items"]) == 50 assert first_page_resp["next_page_params"] != nil diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs new file mode 100644 index 0000000000..dbd829f27c --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs @@ -0,0 +1,55 @@ +defmodule BlockScoutWeb.API.V2.WithdrawalControllerTest do + use BlockScoutWeb.ConnCase + + alias Explorer.Chain.Withdrawal + + describe "/withdrawals" do + test "empty lists", %{conn: conn} do + request = get(conn, "/api/v2/blocks") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "get withdrawal", %{conn: conn} do + block = insert(:withdrawal) + + request = get(conn, "/api/v2/withdrawals") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + compare_item(block, Enum.at(response["items"], 0)) + end + + test "can paginate", %{conn: conn} do + withdrawals = + 51 + |> insert_list(:withdrawal) + + request = get(conn, "/api/v2/withdrawals") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/withdrawals", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, withdrawals) + end + end + + defp compare_item(%Withdrawal{} = withdrawal, json) do + assert withdrawal.index == json["index"] + end + + defp check_paginated_response(first_page_resp, second_page_resp, list) do + assert Enum.count(first_page_resp["items"]) == 50 + assert first_page_resp["next_page_params"] != nil + compare_item(Enum.at(list, 50), Enum.at(first_page_resp["items"], 0)) + compare_item(Enum.at(list, 1), Enum.at(first_page_resp["items"], 49)) + + assert Enum.count(second_page_resp["items"]) == 1 + assert second_page_resp["next_page_params"] == nil + compare_item(Enum.at(list, 0), Enum.at(second_page_resp["items"], 0)) + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs new file mode 100644 index 0000000000..61d3d76c46 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs @@ -0,0 +1,139 @@ +defmodule BlockScoutWeb.BlockWithdrawalControllerTest do + use BlockScoutWeb.ConnCase + + import BlockScoutWeb.WebRouter.Helpers, only: [block_withdrawal_path: 3] + + describe "GET index/2" do + test "with invalid block number", %{conn: conn} do + conn = get(conn, block_withdrawal_path(conn, :index, "unknown")) + + assert html_response(conn, 404) + end + + test "with valid block number below the tip", %{conn: conn} do + insert(:block, number: 666) + + conn = get(conn, block_withdrawal_path(conn, :index, "1")) + + assert html_response(conn, 404) =~ "This block has not been processed yet." + end + + test "with valid block number above the tip", %{conn: conn} do + block = insert(:block) + + conn = get(conn, block_withdrawal_path(conn, :index, block.number + 1)) + + assert_block_above_tip(conn) + end + + test "returns withdrawals for the block", %{conn: conn} do + block = insert(:block, withdrawals: insert_list(3, :withdrawal)) + + # to check that we can render a block overview + get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block)) + conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) + + assert json_response(conn, 200) + + {:ok, %{"items" => items}} = + conn.resp_body + |> Poison.decode() + + assert Enum.count(items) == 3 + end + + test "non-consensus block number without consensus blocks is treated as consensus number above tip", %{conn: conn} do + block = insert(:block, consensus: false) + + transaction = insert(:transaction) + insert(:transaction_fork, hash: transaction.hash, uncle_hash: block.hash) + + conn = get(conn, block_withdrawal_path(conn, :index, block.number)) + + assert_block_above_tip(conn) + end + + test "non-consensus block number above consensus block number is treated as consensus number above tip", %{ + conn: conn + } do + consensus_block = insert(:block, consensus: true, number: 1) + block = insert(:block, consensus: false, number: consensus_block.number + 1) + + transaction = insert(:transaction) + insert(:transaction_fork, hash: transaction.hash, uncle_hash: block.hash) + + conn = get(conn, block_withdrawal_path(conn, :index, block.number)) + + assert_block_above_tip(conn) + end + + test "does not return transactions for invalid block hash", %{conn: conn} do + conn = get(conn, block_withdrawal_path(conn, :index, "0x0")) + + assert html_response(conn, 404) + end + + test "with valid not-indexed hash", %{conn: conn} do + conn = get(conn, block_withdrawal_path(conn, :index, block_hash())) + + assert html_response(conn, 404) =~ "Block not found, please try again later." + end + + test "does not return unrelated transactions", %{conn: conn} do + insert(:withdrawal) + block = insert(:block) + + conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) + + assert json_response(conn, 200) + + {:ok, %{"items" => items}} = + conn.resp_body + |> Poison.decode() + + assert Enum.empty?(items) + end + + test "next_page_path exists if not on last page", %{conn: conn} do + block = insert(:block, withdrawals: insert_list(60, :withdrawal)) + + conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) + + {:ok, %{"next_page_path" => next_page_path}} = + conn.resp_body + |> Poison.decode() + + assert next_page_path + end + + test "next_page_path is empty if on last page", %{conn: conn} do + block = insert(:block, withdrawals: insert_list(1, :withdrawal)) + + conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) + + {:ok, %{"next_page_path" => next_page_path}} = + conn.resp_body + |> Poison.decode() + + refute next_page_path + end + + test "displays miner primary address name", %{conn: conn} do + miner_name = "POA Miner Pool" + %{address: miner_address} = insert(:address_name, name: miner_name, primary: true) + + block = insert(:block, miner: miner_address, miner_hash: nil) + + conn = get(conn, block_withdrawal_path(conn, :index, block)) + assert html_response(conn, 200) =~ miner_name + end + end + + defp assert_block_above_tip(conn) do + assert conn + |> html_response(404) + |> Floki.find(~S|.error-descr|) + |> Floki.text() + |> String.trim() == "Easy Cowboy! This block does not exist yet!" + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs new file mode 100644 index 0000000000..d62fb54191 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs @@ -0,0 +1,60 @@ +defmodule BlockScoutWeb.WithdrawalControllerTest do + use BlockScoutWeb.ConnCase + + import BlockScoutWeb.WebRouter.Helpers, only: [withdrawal_path: 2, withdrawal_path: 3] + + alias Explorer.Chain.Withdrawal + + describe "GET index/2" do + test "returns all withdrawals", %{conn: conn} do + insert_list(4, :withdrawal) + + conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) + + items = Map.get(json_response(conn, 200), "items") + + assert length(items) == 4 + end + + test "returns next page of results based on last withdrawal", %{conn: conn} do + insert_list(50, :withdrawal) + + withdrawal = insert(:withdrawal) + + conn = + get(conn, withdrawal_path(conn, :index), %{ + "type" => "JSON", + "index" => Integer.to_string(withdrawal.index) + }) + + items = Map.get(json_response(conn, 200), "items") + + assert length(items) == 50 + end + + test "next_page_path exist if not on last page", %{conn: conn} do + %Withdrawal{index: index} = + 60 + |> insert_list(:withdrawal) + |> Enum.fetch!(10) + + conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) + + expected_path = + withdrawal_path(conn, :index, %{ + index: index, + items_count: "50" + }) + + assert Map.get(json_response(conn, 200), "next_page_path") == expected_path + end + + test "next_page_path is empty if on last page", %{conn: conn} do + insert(:withdrawal) + + conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) + + refute conn |> json_response(200) |> Map.get("next_page_path") + end + end +end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex index 3b5fbf4470..76e7417c90 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex @@ -261,6 +261,17 @@ defmodule EthereumJSONRPC do |> fetch_blocks_by_params(&Block.ByNumber.request/1, json_rpc_named_arguments) end + @doc """ + Fetches blocks by block number list. + """ + @spec fetch_blocks_by_numbers([block_number()], json_rpc_named_arguments) :: + {:ok, Blocks.t()} | {:error, reason :: term} + def fetch_blocks_by_numbers(block_numbers, json_rpc_named_arguments) do + block_numbers + |> Enum.map(fn number -> %{number: number} end) + |> fetch_blocks_by_params(&Block.ByNumber.request/1, json_rpc_named_arguments) + end + @doc """ Fetches uncle blocks by nephew hashes and indices. """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex index 7c4de364b9..5737aa9096 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex @@ -6,7 +6,7 @@ defmodule EthereumJSONRPC.Block do import EthereumJSONRPC, only: [quantity_to_integer: 1, timestamp_to_datetime: 1] - alias EthereumJSONRPC.{Transactions, Uncles} + alias EthereumJSONRPC.{Transactions, Uncles, Withdrawals} @type elixir :: %{String.t() => non_neg_integer | DateTime.t() | String.t() | nil} @type params :: %{ @@ -29,7 +29,8 @@ defmodule EthereumJSONRPC.Block do total_difficulty: non_neg_integer(), transactions_root: EthereumJSONRPC.hash(), uncles: [EthereumJSONRPC.hash()], - base_fee_per_gas: non_neg_integer() + base_fee_per_gas: non_neg_integer(), + withdrawals_root: EthereumJSONRPC.hash() } @typedoc """ @@ -67,6 +68,7 @@ defmodule EthereumJSONRPC.Block do [uncles](https://bitcoin.stackexchange.com/questions/39329/in-ethereum-what-is-an-uncle-block) `t:EthereumJSONRPC.hash/0`. * `"baseFeePerGas"` - `t:EthereumJSONRPC.quantity/0` of wei to denote amount of fee burned per unit gas used. Introduced in [EIP-1559](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md) + * `"withdrawalsRoot"` - `t:EthereumJSONRPC.hash/0` of the root of the withdrawals. """ @type t :: %{String.t() => EthereumJSONRPC.data() | EthereumJSONRPC.hash() | EthereumJSONRPC.quantity() | nil} @@ -140,7 +142,8 @@ defmodule EthereumJSONRPC.Block do timestamp: Timex.parse!("2017-12-15T21:03:30Z", "{ISO:Extended:Z}"), total_difficulty: 340282366920938463463374607431465668165, transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - uncles: [] + uncles: [], + withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" } [Geth] `elixir` can be converted to params @@ -188,7 +191,8 @@ defmodule EthereumJSONRPC.Block do timestamp: Timex.parse!("2015-07-30T15:32:07Z", "{ISO:Extended:Z}"), total_difficulty: 1039309006117, transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - uncles: [] + uncles: [], + withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" } """ @@ -235,7 +239,9 @@ defmodule EthereumJSONRPC.Block do total_difficulty: total_difficulty, transactions_root: transactions_root, uncles: uncles, - base_fee_per_gas: base_fee_per_gas + base_fee_per_gas: base_fee_per_gas, + withdrawals_root: + Map.get(elixir, "withdrawalsRoot", "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421") } end @@ -279,7 +285,9 @@ defmodule EthereumJSONRPC.Block do timestamp: timestamp, transactions_root: transactions_root, uncles: uncles, - base_fee_per_gas: base_fee_per_gas + base_fee_per_gas: base_fee_per_gas, + withdrawals_root: + Map.get(elixir, "withdrawalsRoot", "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421") } end @@ -323,7 +331,9 @@ defmodule EthereumJSONRPC.Block do timestamp: timestamp, total_difficulty: total_difficulty, transactions_root: transactions_root, - uncles: uncles + uncles: uncles, + withdrawals_root: + Map.get(elixir, "withdrawalsRoot", "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421") } end @@ -366,7 +376,9 @@ defmodule EthereumJSONRPC.Block do state_root: state_root, timestamp: timestamp, transactions_root: transactions_root, - uncles: uncles + uncles: uncles, + withdrawals_root: + Map.get(elixir, "withdrawalsRoot", "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421") } end @@ -506,6 +518,73 @@ defmodule EthereumJSONRPC.Block do |> Enum.map(fn {uncle_hash, index} -> %{"hash" => uncle_hash, "nephewHash" => nephew_hash, "index" => index} end) end + @doc """ + Get `t:EthereumJSONRPC.Withdrawals.elixir/0` from `t:elixir/0`. + + iex> EthereumJSONRPC.Block.elixir_to_withdrawals( + ...> %{ + ...> "baseFeePerGas" => 7, + ...> "difficulty" => 0, + ...> "extraData" => "0x", + ...> "gasLimit" => 7_009_844, + ...> "gasUsed" => 0, + ...> "hash" => "0xc0b72358464dc55cb51c990360d94809e40f291603a7664d55cf83f87edb799d", + ...> "logsBloom" => "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + ...> "miner" => "0xe7c180eada8f60d63e9671867b2e0ca2649207a8", + ...> "mixHash" => "0x9cc5c22d51f47caf700636f629e0765a5fe3388284682434a3717d099960681a", + ...> "nonce" => "0x0000000000000000", + ...> "number" => 541, + ...> "parentHash" => "0x9bc27f8db423bea352a32b819330df307dd351da71f3b3f8ac4ad56856c1e053", + ...> "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + ...> "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + ...> "size" => 1107, + ...> "stateRoot" => "0x9de54b38595b4b8baeece667ae1f7bec8cfc814a514248985e3d98c91d331c71", + ...> "timestamp" => Timex.parse!("2022-12-15T21:06:15Z", "{ISO:Extended:Z}"), + ...> "totalDifficulty" => 1, + ...> "transactions" => [], + ...> "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + ...> "uncles" => [], + ...> "withdrawals" => [ + ...> %{ + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => 4_040_000_000_000, + ...> "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + ...> "index" => 3867, + ...> "validatorIndex" => 1721 + ...> }, + ...> %{ + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => 4_040_000_000_000, + ...> "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + ...> "index" => 3868, + ...> "validatorIndex" => 1771 + ...> } + ...> ], + ...> "withdrawalsRoot" => "0x23e926286a20cba56ee0fcf0eca7aae44f013bd9695aaab58478e8d69b0c3d68" + ...> } + ...> ) + [ + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4040000000000, + "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + "index" => 3867, + "validatorIndex" => 1721 + }, + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4040000000000, + "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + "index" => 3868, + "validatorIndex" => 1771 + } + ] + + """ + @spec elixir_to_withdrawals(elixir) :: Withdrawals.elixir() + def elixir_to_withdrawals(%{"withdrawals" => withdrawals}), do: withdrawals + def elixir_to_withdrawals(_), do: [] + @doc """ Decodes the stringly typed numerical fields to `t:non_neg_integer/0` and the timestamps to `t:DateTime.t/0` @@ -535,7 +614,22 @@ defmodule EthereumJSONRPC.Block do ...> "totalDifficulty" => "0x2ffffffffffffffffffffffffedf78e41", ...> "transactions" => [], ...> "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - ...> "uncles" => [] + ...> "uncles" => [], + ...> "withdrawals" => [ + ...> %{ + ...> "index" => "0xf1b", + ...> "validatorIndex" => "0x6b9", + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => "0x3aca2c3d000" + ...> }, + ...> %{ + ...> "index" => "0xf1c", + ...> "validatorIndex" => "0x6eb", + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => "0x3aca2c3d000" + ...> } + ...> ], + ...> "withdrawalsRoot" => "0x23e926286a20cba56ee0fcf0eca7aae44f013bd9695aaab58478e8d69b0c3d68" ...> } ...> ) %{ @@ -563,43 +657,71 @@ defmodule EthereumJSONRPC.Block do "totalDifficulty" => 1020847100762815390390123822295002091073, "transactions" => [], "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] + "uncles" => [], + "withdrawals" => [ + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4_040_000_000_000, + "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + "index" => 3867, + "blockNumber" => 3, + "validatorIndex" => 1721 + }, + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4_040_000_000_000, + "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + "index" => 3868, + "blockNumber" => 3, + "validatorIndex" => 1771 + } + ], + "withdrawalsRoot" => "0x23e926286a20cba56ee0fcf0eca7aae44f013bd9695aaab58478e8d69b0c3d68" } """ def to_elixir(block) when is_map(block) do - Enum.into(block, %{}, &entry_to_elixir/1) + Enum.into(block, %{}, &entry_to_elixir(&1, block)) end - defp entry_to_elixir({key, quantity}) + defp entry_to_elixir({key, quantity}, _block) when key in ~w(difficulty gasLimit gasUsed minimumGasPrice baseFeePerGas number size cumulativeDifficulty totalDifficulty paidFees) and not is_nil(quantity) do {key, quantity_to_integer(quantity)} end # Size and totalDifficulty may be `nil` for uncle blocks - defp entry_to_elixir({key, nil}) when key in ~w(size totalDifficulty) do + defp entry_to_elixir({key, nil}, _block) when key in ~w(size totalDifficulty) do {key, nil} end # double check that no new keys are being missed by requiring explicit match for passthrough # `t:EthereumJSONRPC.address/0` and `t:EthereumJSONRPC.hash/0` pass through as `Explorer.Chain` can verify correct # hash format - defp entry_to_elixir({key, _} = entry) + defp entry_to_elixir({key, _} = entry, _block) when key in ~w(author extraData hash logsBloom miner mixHash nonce parentHash receiptsRoot sealFields sha3Uncles - signature stateRoot step transactionsRoot uncles), + signature stateRoot step transactionsRoot uncles withdrawalsRoot), do: entry - defp entry_to_elixir({"timestamp" = key, timestamp}) do + defp entry_to_elixir({"timestamp" = key, timestamp}, _block) do {key, timestamp_to_datetime(timestamp)} end - defp entry_to_elixir({"transactions" = key, transactions}) do + defp entry_to_elixir({"transactions" = key, transactions}, _block) do {key, Transactions.to_elixir(transactions)} end + defp entry_to_elixir({"withdrawals" = key, nil}, _block) do + {key, []} + end + + defp entry_to_elixir({"withdrawals" = key, withdrawals}, %{"hash" => block_hash, "number" => block_number}) + when not is_nil(block_number) do + {key, Withdrawals.to_elixir(withdrawals, block_hash, quantity_to_integer(block_number))} + end + # Arbitrum fields - defp entry_to_elixir({"l1BlockNumber", _}) do + defp entry_to_elixir({"l1BlockNumber", _}, _block) do {:ignore, :ignore} end @@ -609,7 +731,7 @@ defmodule EthereumJSONRPC.Block do # blockExtraData extDataHash - Avalanche https://github.com/blockscout/blockscout/pull/5348 # vrf vrfProof - Harmony # ... - defp entry_to_elixir({_, _}) do + defp entry_to_elixir({_, _}, _block) do {:ignore, :ignore} end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex index dc1740a4aa..8e76783b40 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex @@ -4,7 +4,7 @@ defmodule EthereumJSONRPC.Blocks do and [`eth_getBlockByNumber`](https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_getblockbynumber) from batch requests. """ - alias EthereumJSONRPC.{Block, Transactions, Transport, Uncles} + alias EthereumJSONRPC.{Block, Transactions, Transport, Uncles, Withdrawals} @type elixir :: [Block.elixir()] @type params :: [Block.params()] @@ -12,12 +12,14 @@ defmodule EthereumJSONRPC.Blocks do blocks_params: [map()], block_second_degree_relations_params: [map()], transactions_params: [map()], + withdrawals_params: Withdrawals.params(), errors: [Transport.error()] } defstruct blocks_params: [], block_second_degree_relations_params: [], transactions_params: [], + withdrawals_params: [], errors: [] def requests(id_to_params, request) when is_map(id_to_params) and is_function(request, 1) do @@ -45,16 +47,19 @@ defmodule EthereumJSONRPC.Blocks do elixir_uncles = elixir_to_uncles(elixir_blocks) elixir_transactions = elixir_to_transactions(elixir_blocks) + elixir_withdrawals = elixir_to_withdrawals(elixir_blocks) block_second_degree_relations_params = Uncles.elixir_to_params(elixir_uncles) transactions_params = Transactions.elixir_to_params(elixir_transactions) + withdrawals_params = Withdrawals.elixir_to_params(elixir_withdrawals) blocks_params = elixir_to_params(elixir_blocks) %__MODULE__{ errors: errors, blocks_params: blocks_params, block_second_degree_relations_params: block_second_degree_relations_params, - transactions_params: transactions_params + transactions_params: transactions_params, + withdrawals_params: withdrawals_params } end @@ -110,7 +115,8 @@ defmodule EthereumJSONRPC.Blocks do timestamp: Timex.parse!("1970-01-01T00:00:00Z", "{ISO:Extended:Z}"), total_difficulty: 131072, transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - uncles: ["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311"] + uncles: ["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311"], + withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" } ] @@ -271,6 +277,74 @@ defmodule EthereumJSONRPC.Blocks do Enum.flat_map(elixir, &Block.elixir_to_uncles/1) end + @doc """ + Extracts the `t:EthereumJSONRPC.Withdrawals.elixir/0` from the `t:elixir/0`. + + iex> EthereumJSONRPC.Blocks.elixir_to_withdrawals([ + ...> %{ + ...> "baseFeePerGas" => 7, + ...> "difficulty" => 0, + ...> "extraData" => "0x", + ...> "gasLimit" => 7_009_844, + ...> "gasUsed" => 0, + ...> "hash" => "0xc0b72358464dc55cb51c990360d94809e40f291603a7664d55cf83f87edb799d", + ...> "logsBloom" => "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + ...> "miner" => "0xe7c180eada8f60d63e9671867b2e0ca2649207a8", + ...> "mixHash" => "0x9cc5c22d51f47caf700636f629e0765a5fe3388284682434a3717d099960681a", + ...> "nonce" => "0x0000000000000000", + ...> "number" => 541, + ...> "parentHash" => "0x9bc27f8db423bea352a32b819330df307dd351da71f3b3f8ac4ad56856c1e053", + ...> "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + ...> "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + ...> "size" => 1107, + ...> "stateRoot" => "0x9de54b38595b4b8baeece667ae1f7bec8cfc814a514248985e3d98c91d331c71", + ...> "timestamp" => Timex.parse!("2022-12-15T21:06:15Z", "{ISO:Extended:Z}"), + ...> "totalDifficulty" => 1, + ...> "transactions" => [], + ...> "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + ...> "uncles" => [], + ...> "withdrawals" => [ + ...> %{ + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => 4_040_000_000_000, + ...> "blockHash" => "0xc0b72358464dc55cb51c990360d94809e40f291603a7664d55cf83f87edb799d", + ...> "index" => 3867, + ...> "validatorIndex" => 1721 + ...> }, + ...> %{ + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => 4_040_000_000_000, + ...> "blockHash" => "0xc0b72358464dc55cb51c990360d94809e40f291603a7664d55cf83f87edb799d", + ...> "index" => 3868, + ...> "validatorIndex" => 1771 + ...> } + ...> ], + ...> "withdrawalsRoot" => "0x23e926286a20cba56ee0fcf0eca7aae44f013bd9695aaab58478e8d69b0c3d68" + ...> } + ...> ]) + [ + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4040000000000, + "blockHash" => "0xc0b72358464dc55cb51c990360d94809e40f291603a7664d55cf83f87edb799d", + "index" => 3867, + "validatorIndex" => 1721 + }, + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4040000000000, + "blockHash" => "0xc0b72358464dc55cb51c990360d94809e40f291603a7664d55cf83f87edb799d", + "index" => 3868, + "validatorIndex" => 1771 + } + ] + + """ + @spec elixir_to_withdrawals(elixir) :: Withdrawals.elixir() + def elixir_to_withdrawals(elixir) do + Enum.flat_map(elixir, &Block.elixir_to_withdrawals/1) + end + @doc """ Decodes the stringly typed numerical fields to `t:non_neg_integer/0` and the timestamps to `t:DateTime.t/0` @@ -299,7 +373,22 @@ defmodule EthereumJSONRPC.Blocks do ...> "totalDifficulty" => "0x20000", ...> "transactions" => [], ...> "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - ...> "uncles" => [] + ...> "uncles" => [], + ...> "withdrawals" => [ + ...> %{ + ...> "index" => "0xf1b", + ...> "validatorIndex" => "0x6b9", + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => "0x3aca2c3d000" + ...> }, + ...> %{ + ...> "index" => "0xf1c", + ...> "validatorIndex" => "0x6eb", + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => "0x3aca2c3d000" + ...> } + ...> ], + ...> "withdrawalsRoot" => "0x23e926286a20cba56ee0fcf0eca7aae44f013bd9695aaab58478e8d69b0c3d68" ...> } ...> ] ...> ) @@ -327,7 +416,26 @@ defmodule EthereumJSONRPC.Blocks do "totalDifficulty" => 131072, "transactions" => [], "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] + "uncles" => [], + "withdrawals" => [ + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4_040_000_000_000, + "blockHash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", + "index" => 3867, + "validatorIndex" => 1721, + "blockNumber" => 0 + }, + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4_040_000_000_000, + "blockHash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", + "index" => 3868, + "validatorIndex" => 1771, + "blockNumber" => 0 + } + ], + "withdrawalsRoot" => "0x23e926286a20cba56ee0fcf0eca7aae44f013bd9695aaab58478e8d69b0c3d68" } ] """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex new file mode 100644 index 0000000000..6c86c73d43 --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex @@ -0,0 +1,101 @@ +defmodule EthereumJSONRPC.Withdrawal do + @moduledoc """ + Withdrawal format included in the return of + `eth_getBlockByHash` and `eth_getBlockByNumber` + """ + + import EthereumJSONRPC, only: [quantity_to_integer: 1] + + @type elixir :: %{ + String.t() => EthereumJSONRPC.address() | EthereumJSONRPC.hash() | String.t() | non_neg_integer() | nil + } + + @typedoc """ + * `"index"` - the withdrawal number `t:EthereumJSONRPC.quantity/0`. + * `"validatorIndex"` - the validator number initiated the withdrawal `t:EthereumJSONRPC.quantity/0`. + * `"address"` - `t:EthereumJSONRPC.address/0` of the receiver. + * `"amount"` - `t:EthereumJSONRPC.quantity/0` of wei transferred. + """ + @type t :: %{ + String.t() => + EthereumJSONRPC.address() | EthereumJSONRPC.hash() | EthereumJSONRPC.quantity() | String.t() | nil + } + + @type params :: %{ + index: non_neg_integer(), + validator_index: non_neg_integer(), + address_hash: EthereumJSONRPC.address(), + block_hash: EthereumJSONRPC.hash(), + block_number: non_neg_integer(), + amount: non_neg_integer() + } + + @doc """ + Converts `t:elixir/0` to `t:params/0`. + + iex> EthereumJSONRPC.Withdrawal.elixir_to_params( + ...> %{ + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => 4040000000000, + ...> "index" => 3867, + ...> "validatorIndex" => 1721, + ...> "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + ...> "blockNumber" => 3 + ...> } + ...> ) + %{ + address_hash: "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + amount: 4040000000000, + block_hash: "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + block_number: 3, + index: 3867, + validator_index: 1721 + } + """ + @spec elixir_to_params(elixir) :: params + def elixir_to_params(%{ + "index" => index, + "validatorIndex" => validator_index, + "address" => address_hash, + "amount" => amount, + "blockHash" => block_hash, + "blockNumber" => block_number + }) do + %{ + index: index, + validator_index: validator_index, + address_hash: address_hash, + block_hash: block_hash, + block_number: block_number, + amount: amount + } + end + + @doc """ + Decodes the stringly typed numerical fields to `t:non_neg_integer/0`. + + iex> EthereumJSONRPC.Withdrawal.to_elixir( + ...> %{ + ...> "index" => "0xf1b", + ...> "validatorIndex" => "0x6b9", + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => "0x3aca2c3d000" + ...> }, "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", 1 + ...> ) + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4040000000000, + "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + "index" => 3867, + "validatorIndex" => 1721, + "blockNumber" => 1 + } + """ + @spec to_elixir(%{String.t() => String.t()}, String.t(), non_neg_integer()) :: elixir + def to_elixir(withdrawal, block_hash, block_number) when is_map(withdrawal) do + Enum.into(withdrawal, %{"blockHash" => block_hash, "blockNumber" => block_number}, &entry_to_elixir/1) + end + + defp entry_to_elixir({key, value}) when key in ~w(index validatorIndex amount), do: {key, quantity_to_integer(value)} + defp entry_to_elixir({key, value}) when key in ~w(address), do: {key, value} +end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex new file mode 100644 index 0000000000..1c520b9f9a --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex @@ -0,0 +1,67 @@ +defmodule EthereumJSONRPC.Withdrawals do + @moduledoc """ + List of withdrawals format included in the return of + `eth_getBlockByHash` and `eth_getBlockByNumber` + """ + + alias EthereumJSONRPC.Withdrawal + + @type elixir :: [Withdrawal.elixir()] + @type params :: [Withdrawal.params()] + @type t :: [Withdrawal.t()] + + @doc """ + Converts `t:elixir/0` to `t:params/0`. + + iex> EthereumJSONRPC.Withdrawals.elixir_to_params([ + ...> %{ + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => 4040000000000, + ...> "index" => 3867, + ...> "validatorIndex" => 1721, + ...> "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + ...> "blockNumber" => 1 + ...> } + ...> ]) + [ + %{ + address_hash: "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + amount: 4040000000000, + block_hash: "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + index: 3867, + validator_index: 1721, + block_number: 1 + } + ] + """ + @spec elixir_to_params(elixir) :: params + def elixir_to_params(elixir) when is_list(elixir) do + Enum.map(elixir, &Withdrawal.elixir_to_params/1) + end + + @doc """ + Decodes stringly typed fields in entries of `withdrawals`. + + iex> EthereumJSONRPC.Withdrawals.to_elixir([ + ...> %{ + ...> "index" => "0xf1b", + ...> "validatorIndex" => "0x6b9", + ...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + ...> "amount" => "0x3aca2c3d000" + ...> }], "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", 3) + [ + %{ + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => 4040000000000, + "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", + "index" => 3867, + "blockNumber" => 3, + "validatorIndex" => 1721 + } + ] + """ + @spec to_elixir([%{String.t() => String.t()}], String.t(), non_neg_integer()) :: elixir + def to_elixir(withdrawals, block_hash, block_number) when is_list(withdrawals) do + Enum.map(withdrawals, &Withdrawal.to_elixir(&1, block_hash, block_number)) + end +end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs index 23e1e442c5..c409ecd99c 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs @@ -52,7 +52,8 @@ defmodule EthereumJSONRPC.BlockTest do timestamp: Timex.parse!("2015-07-30T15:32:07Z", "{ISO:Extended:Z}"), total_difficulty: nil, transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - uncles: [] + uncles: [], + withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" } end end @@ -62,4 +63,14 @@ defmodule EthereumJSONRPC.BlockTest do assert Block.elixir_to_transactions(%{}) == [] end end + + describe "elixir_to_withdrawals/1" do + test "converts to empty list if there is no withdrawals key" do + assert Block.elixir_to_withdrawals(%{}) == [] + end + + test "converts to empty list if withdrawals is nil" do + assert Block.elixir_to_withdrawals(%{withdrawals: nil}) == [] + end + end end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs new file mode 100644 index 0000000000..8f8214c62d --- /dev/null +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs @@ -0,0 +1,5 @@ +defmodule EthereumJSONRPC.WithdrawalTest do + use ExUnit.Case, async: true + + doctest EthereumJSONRPC.Withdrawal +end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs new file mode 100644 index 0000000000..a4d4b48459 --- /dev/null +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs @@ -0,0 +1,5 @@ +defmodule EthereumJSONRPC.WithdrawalsTest do + use ExUnit.Case, async: true + + doctest EthereumJSONRPC.Withdrawals +end diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 802cbe6c2a..3b89dbf9d4 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -61,7 +61,8 @@ defmodule Explorer.Chain do Token.Instance, TokenTransfer, Transaction, - Wei + Wei, + Withdrawal } alias Explorer.Chain.Block.{EmissionReward, Reward} @@ -615,6 +616,21 @@ defmodule Explorer.Chain do |> select_repo(options).all() end + @spec address_hash_to_withdrawals( + Hash.Address.t(), + [paging_options | necessity_by_association_option] + ) :: [Withdrawal.t()] + def address_hash_to_withdrawals(address_hash, options \\ []) when is_list(options) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + address_hash + |> Withdrawal.address_hash_to_withdrawals_query() + |> join_associations(necessity_by_association) + |> handle_withdrawals_paging_options(paging_options) + |> select_repo(options).all() + end + @doc """ address_hash_to_token_transfers_including_contract/2 function returns token transfers on address (to/from/contract). It is used by CSV export of token transfers button. @@ -990,6 +1006,21 @@ defmodule Explorer.Chain do )).() end + @spec block_to_withdrawals( + Hash.Full.t(), + [paging_options | necessity_by_association_option] + ) :: [Withdrawal.t()] + def block_to_withdrawals(block_hash, options \\ []) when is_list(options) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + block_hash + |> Withdrawal.block_hash_to_withdrawals_query() + |> join_associations(necessity_by_association) + |> handle_withdrawals_paging_options(paging_options) + |> select_repo(options).all() + end + @doc """ Finds sum of gas_used for new (EIP-1559) txs belongs to block """ @@ -1066,6 +1097,13 @@ defmodule Explorer.Chain do Repo.aggregate(query, :count, :hash) end + @spec check_if_withdrawals_in_block(Hash.Full.t()) :: boolean() + def check_if_withdrawals_in_block(block_hash, options \\ []) do + block_hash + |> Withdrawal.block_hash_to_withdrawals_unordered_query() + |> select_repo(options).exists?() + end + @spec address_to_incoming_transaction_count(Hash.Address.t()) :: non_neg_integer() def address_to_incoming_transaction_count(address_hash) do to_address_query = @@ -2665,6 +2703,13 @@ defmodule Explorer.Chain do ) end + @spec check_if_withdrawals_at_address(Hash.Address.t()) :: boolean() + def check_if_withdrawals_at_address(address_hash, options \\ []) do + address_hash + |> Withdrawal.address_hash_to_withdrawals_unordered_query() + |> select_repo(options).exists?() + end + @doc """ Counts all of the block validations and groups by the `miner_hash`. """ @@ -4578,6 +4623,14 @@ defmodule Explorer.Chain do |> limit(^paging_options.page_size) end + defp handle_withdrawals_paging_options(query, nil), do: query + + defp handle_withdrawals_paging_options(query, paging_options) do + query + |> Withdrawal.page_withdrawals(paging_options) + |> limit(^paging_options.page_size) + end + defp handle_random_access_paging_options(query, empty_options) when empty_options in [nil, [], %{}], do: limit(query, ^(@default_page_size + 1)) @@ -6797,5 +6850,15 @@ defmodule Explorer.Chain do watchlist_names = Enum.reduce(watchlist_addresses, %{}, fn wa, acc -> Map.put(acc, wa.address_hash, wa.name) end) {watchlist_names, address_hashes_to_mined_transactions_without_rewards(address_hashes, options)} + end + + def list_withdrawals(options \\ []) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + Withdrawal.list_withdrawals() + |> join_associations(necessity_by_association) + |> handle_withdrawals_paging_options(paging_options) + |> select_repo(options).all() end end diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index 2b60f9ffe9..44b317a42b 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -20,7 +20,8 @@ defmodule Explorer.Chain.Address do SmartContractAdditionalSource, Token, Transaction, - Wei + Wei, + Withdrawal } alias Explorer.Chain.Cache.NetVersion @@ -120,6 +121,7 @@ defmodule Explorer.Chain.Address do has_many(:names, Address.Name, foreign_key: :address_hash) has_many(:decompiled_smart_contracts, DecompiledSmartContract, foreign_key: :address_hash) has_many(:smart_contract_additional_sources, SmartContractAdditionalSource, foreign_key: :address_hash) + has_many(:withdrawals, Withdrawal, foreign_key: :address_hash) timestamps() end diff --git a/apps/explorer/lib/explorer/chain/block.ex b/apps/explorer/lib/explorer/chain/block.ex index dfd4512644..d427ea476c 100644 --- a/apps/explorer/lib/explorer/chain/block.ex +++ b/apps/explorer/lib/explorer/chain/block.ex @@ -7,7 +7,7 @@ defmodule Explorer.Chain.Block do use Explorer.Schema - alias Explorer.Chain.{Address, Gas, Hash, PendingBlockOperation, Transaction, Wei} + alias Explorer.Chain.{Address, Gas, Hash, PendingBlockOperation, Transaction, Wei, Withdrawal} alias Explorer.Chain.Block.{Reward, SecondDegreeRelation} @optional_attrs ~w(size refetch_needed total_difficulty difficulty base_fee_per_gas)a @@ -100,6 +100,8 @@ defmodule Explorer.Chain.Block do has_many(:rewards, Reward, foreign_key: :block_hash) + has_many(:withdrawals, Withdrawal, foreign_key: :block_hash) + has_one(:pending_operations, PendingBlockOperation, foreign_key: :block_hash) end diff --git a/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex b/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex new file mode 100644 index 0000000000..1c84df6141 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex @@ -0,0 +1,106 @@ +defmodule Explorer.Chain.Import.Runner.Withdrawals do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Withdrawal.t/0`. + """ + + require Ecto.Query + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.{Import, Withdrawal} + alias Explorer.Prometheus.Instrumenter + + import Ecto.Query, only: [from: 2] + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [Withdrawal.t()] + + @impl Import.Runner + def ecto_schema_module, do: Withdrawal + + @impl Import.Runner + def option_key, do: :withdrawals + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :withdrawals, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :withdrawals, + :withdrawals + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{ + optional(:on_conflict) => Import.Runner.on_conflict(), + required(:timeout) => timeout, + required(:timestamps) => Import.timestamps() + }) :: + {:ok, [Withdrawal.t()]} + | {:error, [Changeset.t()]} + defp insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + # Enforce Withdrawal ShareLocks order (see docs: sharelocks.md) + ordered_changes_list = Enum.sort_by(changes_list, & &1.index) + + {:ok, _} = + Import.insert_changes_list( + repo, + ordered_changes_list, + conflict_target: [:index], + on_conflict: on_conflict, + for: Withdrawal, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + end + + defp default_on_conflict do + from( + withdrawal in Withdrawal, + update: [ + set: [ + validator_index: fragment("EXCLUDED.validator_index"), + amount: fragment("EXCLUDED.amount"), + address_hash: fragment("EXCLUDED.address_hash"), + block_hash: fragment("EXCLUDED.block_hash"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", withdrawal.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", withdrawal.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.validator_index, EXCLUDED.amount, EXCLUDED.address_hash, EXCLUDED.block_hash) IS DISTINCT FROM (?, ?, ?, ?)", + withdrawal.validator_index, + withdrawal.amount, + withdrawal.address_hash, + withdrawal.block_hash + ) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex b/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex index 13362daf81..578e4b9894 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex @@ -18,7 +18,8 @@ defmodule Explorer.Chain.Import.Stage.BlockReferencing do Runner.Tokens, Runner.TokenTransfers, Runner.Address.TokenBalances, - Runner.TransactionActions + Runner.TransactionActions, + Runner.Withdrawals ] @impl Stage diff --git a/apps/explorer/lib/explorer/chain/withdrawal.ex b/apps/explorer/lib/explorer/chain/withdrawal.ex new file mode 100644 index 0000000000..9e1df561ea --- /dev/null +++ b/apps/explorer/lib/explorer/chain/withdrawal.ex @@ -0,0 +1,113 @@ +defmodule Explorer.Chain.Withdrawal do + @moduledoc """ + A stored representation of withdrawal introduced in [EIP-4895](https://eips.ethereum.org/EIPS/eip-4895) + """ + + use Explorer.Schema + + alias Explorer.Chain.{Address, Block, Hash, Wei} + alias Explorer.PagingOptions + + @type t :: %__MODULE__{ + index: non_neg_integer(), + validator_index: non_neg_integer(), + amount: Wei.t(), + block: %Ecto.Association.NotLoaded{} | Block.t(), + block_hash: Hash.Full.t(), + address: %Ecto.Association.NotLoaded{} | Address.t(), + address_hash: Hash.Address.t() + } + + @required_attrs ~w(index validator_index amount address_hash block_hash)a + + @primary_key {:index, :integer, autogenerate: false} + schema "withdrawals" do + field(:validator_index, :integer) + field(:amount, Wei) + + belongs_to(:address, Address, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address + ) + + belongs_to(:block, Block, + foreign_key: :block_hash, + references: :hash, + type: Hash.Full + ) + + timestamps() + end + + @spec changeset( + Explorer.Chain.Withdrawal.t(), + :invalid | %{optional(:__struct__) => none, optional(atom | binary) => any} + ) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = withdrawal, attrs \\ %{}) do + withdrawal + |> cast(attrs, @required_attrs) + |> validate_required(@required_attrs) + |> unique_constraint(:index, name: :withdrawals_pkey) + end + + @spec page_withdrawals(Ecto.Query.t(), PagingOptions.t()) :: Ecto.Query.t() + def page_withdrawals(query, %PagingOptions{key: nil}), do: query + + def page_withdrawals(query, %PagingOptions{key: {index}}) do + where(query, [withdrawal], withdrawal.index < ^index) + end + + @spec block_hash_to_withdrawals_query(Hash.Full.t()) :: Ecto.Query.t() + def block_hash_to_withdrawals_query(block_hash) do + block_hash + |> block_hash_to_withdrawals_unordered_query() + |> order_by(desc: :index) + end + + @spec block_hash_to_withdrawals_unordered_query(Hash.Full.t()) :: Ecto.Query.t() + def block_hash_to_withdrawals_unordered_query(block_hash) do + from(withdrawal in __MODULE__, + select: withdrawal, + where: withdrawal.block_hash == ^block_hash + ) + end + + @spec address_hash_to_withdrawals_query(Hash.Address.t()) :: Ecto.Query.t() + def address_hash_to_withdrawals_query(address_hash) do + address_hash + |> address_hash_to_withdrawals_unordered_query() + |> order_by(desc: :index) + end + + @spec address_hash_to_withdrawals_unordered_query(Hash.Address.t()) :: Ecto.Query.t() + def address_hash_to_withdrawals_unordered_query(address_hash) do + from(withdrawal in __MODULE__, + select: withdrawal, + left_join: block in assoc(withdrawal, :block), + where: withdrawal.address_hash == ^address_hash, + where: block.consensus, + preload: [block: block] + ) + end + + @spec blocks_without_withdrawals_query(non_neg_integer()) :: Ecto.Query.t() + def blocks_without_withdrawals_query(from_block) do + from(withdrawal in __MODULE__, + right_join: block in assoc(withdrawal, :block), + select: block.number, + distinct: block.number, + where: block.number >= ^from_block, + where: block.consensus == ^true, + where: is_nil(withdrawal.index) + ) + end + + @spec list_withdrawals :: Ecto.Query.t() + def list_withdrawals do + from(withdrawal in __MODULE__, + select: withdrawal, + order_by: [desc: :index] + ) + end +end diff --git a/apps/explorer/lib/explorer/helper.ex b/apps/explorer/lib/explorer/helper.ex index e259f39a98..efbcfe1561 100644 --- a/apps/explorer/lib/explorer/helper.ex +++ b/apps/explorer/lib/explorer/helper.ex @@ -2,6 +2,9 @@ defmodule Explorer.Helper do @moduledoc """ Common explorer helper """ + + def parse_integer(nil), do: nil + def parse_integer(string) do case Integer.parse(string) do {number, ""} -> number diff --git a/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs b/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs new file mode 100644 index 0000000000..aaf307f081 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs @@ -0,0 +1,19 @@ +defmodule Explorer.Repo.Migrations.CreareWithdrawals do + use Ecto.Migration + + def change do + create table(:withdrawals, primary_key: false) do + add(:index, :integer, null: false, primary_key: true) + add(:validator_index, :integer, null: false) + add(:amount, :numeric, precision: 100, null: false) + + timestamps(null: false, type: :utc_datetime_usec) + + add(:address_hash, references(:addresses, column: :hash, on_delete: :delete_all, type: :bytea), null: false) + add(:block_hash, references(:blocks, column: :hash, on_delete: :delete_all, type: :bytea), null: false) + end + + create(index(:withdrawals, [:address_hash])) + create(index(:withdrawals, [:block_hash])) + end +end diff --git a/apps/explorer/test/explorer/chain/withdrawal_test.exs b/apps/explorer/test/explorer/chain/withdrawal_test.exs new file mode 100644 index 0000000000..ebf88f3499 --- /dev/null +++ b/apps/explorer/test/explorer/chain/withdrawal_test.exs @@ -0,0 +1,82 @@ +defmodule Explorer.Chain.WithdrawalTest do + use Explorer.DataCase + + alias Ecto.Changeset + alias Explorer.Chain.Withdrawal + alias Explorer.Chain + + describe "changeset/2" do + test "with valid attributes" do + assert %Changeset{valid?: true} = + :withdrawal + |> build() + |> Withdrawal.changeset(%{}) + end + + test "with invalid attributes" do + changeset = %Withdrawal{} |> Withdrawal.changeset(%{racecar: "yellow ham"}) + refute(changeset.valid?) + end + + test "with duplicate information" do + %Withdrawal{index: index} = insert(:withdrawal) + + assert {:error, %Changeset{valid?: false, errors: [index: {"has already been taken", _}]}} = + %Withdrawal{} + |> Withdrawal.changeset(params_for(:withdrawal, index: index)) + |> Repo.insert() + end + end + + describe "block_hash_to_withdrawals_query/1" do + test "finds only withdrawals of this block" do + withdrawal_a = insert(:withdrawal) + withdrawal_b = insert(:withdrawal) + + results = + Withdrawal.block_hash_to_withdrawals_query(withdrawal_a.block_hash) + |> Repo.all() + |> Enum.map(& &1.index) + + refute Enum.member?(results, withdrawal_b.index) + assert Enum.member?(results, withdrawal_a.index) + end + + test "order the results DESC by index" do + block = insert(:block, withdrawals: insert_list(50, :withdrawal)) + + results = + Withdrawal.block_hash_to_withdrawals_query(block.hash) + |> Repo.all() + |> Enum.map(& &1.index) + + assert results |> Enum.sort(:desc) == results + end + end + + describe "address_hash_to_withdrawals_query/1" do + test "finds only withdrawals of this address" do + withdrawal_a = insert(:withdrawal) + withdrawal_b = insert(:withdrawal) + + results = + Withdrawal.address_hash_to_withdrawals_query(withdrawal_a.address_hash) + |> Repo.all() + |> Enum.map(& &1.index) + + refute Enum.member?(results, withdrawal_b.index) + assert Enum.member?(results, withdrawal_a.index) + end + + test "order the results DESC by index" do + address = insert(:address, withdrawals: insert_list(50, :withdrawal)) + + results = + Withdrawal.address_hash_to_withdrawals_query(address.hash) + |> Repo.all() + |> Enum.map(& &1.index) + + assert results |> Enum.sort(:desc) == results + end + end +end diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index 06c3d0fe84..89799b759e 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -39,7 +39,8 @@ defmodule Explorer.Factory do Token, TokenTransfer, Token.Instance, - Transaction + Transaction, + Withdrawal } alias Explorer.SmartContract.Helper @@ -947,5 +948,28 @@ defmodule Explorer.Factory do } end + def withdrawal_factory do + block = build(:block) + address = build(:address) + + %Withdrawal{ + index: withdrawal_index(), + validator_index: withdrawal_validator_index(), + amount: Enum.random(1..100_000), + block: block, + block_hash: block.hash, + address: address, + address_hash: address.hash + } + end + + def withdrawal_index do + sequence("withdrawal_index", & &1) + end + + def withdrawal_validator_index do + sequence("withdrawal_validator_index", & &1) + end + def random_bool, do: Enum.random([true, false]) end diff --git a/apps/indexer/README.md b/apps/indexer/README.md index f7c0217d16..60d286cdf5 100644 --- a/apps/indexer/README.md +++ b/apps/indexer/README.md @@ -31,6 +31,7 @@ Some data has to be extracted from already fetched data, and there're several tr - `block/realtime`: listens for new blocks from websocket and polls node for new blocks, imports new ones one by one - `block/catchup`: gets unfetched ranges of blocks, imports them in batches - `transaction_action`: optionally fetches/rewrites transaction actions for old blocks (in a given range of blocks for given protocols) +- `withdrawals`: optionally fetches withdrawals for old blocks (in the given from boundary of block numbers) Both block fetchers retrieve/extract the blocks themselves and the following additional data: @@ -40,6 +41,7 @@ Both block fetchers retrieve/extract the blocks themselves and the following add - `token_transfers` - `transaction_actions` - `addresses` +- `withdrawals` The following stubs for further async fetching are inserted as well: diff --git a/apps/indexer/config/dev.exs b/apps/indexer/config/dev.exs index f7f9b205d9..28f928baa1 100644 --- a/apps/indexer/config/dev.exs +++ b/apps/indexer/config/dev.exs @@ -35,3 +35,8 @@ config :logger, :block_import_timings, level: :debug, path: Path.absname("logs/dev/indexer/block_import_timings.log"), metadata_filter: [fetcher: :block_import_timings] + +config :logger, :withdrawal, + level: :debug, + path: Path.absname("logs/dev/indexer/withdrawal.log"), + metadata_filter: [fetcher: :withdrawal] diff --git a/apps/indexer/config/prod.exs b/apps/indexer/config/prod.exs index 7c92ca5853..8e81a078f4 100644 --- a/apps/indexer/config/prod.exs +++ b/apps/indexer/config/prod.exs @@ -42,3 +42,9 @@ config :logger, :block_import_timings, path: Path.absname("logs/prod/indexer/block_import_timings.log"), metadata_filter: [fetcher: :block_import_timings], rotate: %{max_bytes: 52_428_800, keep: 19} + +config :logger, :withdrawal, + level: :info, + path: Path.absname("logs/prod/indexer/withdrawal.log"), + metadata_filter: [fetcher: :withdrawal], + rotate: %{max_bytes: 52_428_800, keep: 19} diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index 77b4056b76..217cf6e13f 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -130,6 +130,7 @@ defmodule Indexer.Block.Fetcher do %Blocks{ blocks_params: blocks_params, transactions_params: transactions_params_without_receipts, + withdrawals_params: withdrawals_params, block_second_degree_relations_params: block_second_degree_relations_params, errors: blocks_errors }}} <- {:blocks, fetched_blocks}, @@ -150,14 +151,16 @@ defmodule Indexer.Block.Fetcher do mint_transfers: mint_transfers, token_transfers: token_transfers, transactions: transactions_with_receipts, - transaction_actions: transaction_actions + transaction_actions: transaction_actions, + withdrawals: withdrawals_params }), coin_balances_params_set = %{ beneficiary_params: MapSet.to_list(beneficiary_params_set), blocks_params: blocks, logs_params: logs, - transactions_params: transactions_with_receipts + transactions_params: transactions_with_receipts, + withdrawals: withdrawals_params } |> AddressCoinBalances.params_set(), coin_balances_params_daily_set = @@ -186,7 +189,8 @@ defmodule Indexer.Block.Fetcher do token_transfers: %{params: token_transfers}, tokens: %{on_conflict: :nothing, params: tokens}, transactions: %{params: transactions_with_receipts}, - transaction_actions: %{params: transaction_actions} + transaction_actions: %{params: transaction_actions}, + withdrawals: %{params: withdrawals_params} } ) do Prometheus.Instrumenter.block_batch_fetch(fetch_time, callback_module) diff --git a/apps/indexer/lib/indexer/fetcher/withdrawal.ex b/apps/indexer/lib/indexer/fetcher/withdrawal.ex new file mode 100644 index 0000000000..eb700cd2ba --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/withdrawal.ex @@ -0,0 +1,159 @@ +defmodule Indexer.Fetcher.Withdrawal do + @moduledoc """ + Reindexes withdrawals from blocks that were indexed before app update. + """ + + use GenServer + use Indexer.Fetcher + + require Logger + + alias EthereumJSONRPC.Blocks + alias Explorer.{Chain, Repo} + alias Explorer.Chain.Withdrawal + alias Explorer.Helper + alias Indexer.Transform.Addresses + + @interval :timer.seconds(10) + @batch_size 10 + @concurrency 5 + + defstruct blocks_to_fetch: [], + interval: @interval, + json_rpc_named_arguments: [], + max_batch_size: @batch_size, + max_concurrency: @concurrency + + def child_spec([init_arguments]) do + child_spec([init_arguments, []]) + end + + def child_spec([_init_arguments, _gen_server_options] = start_link_arguments) do + default = %{ + id: __MODULE__, + start: {__MODULE__, :start_link, start_link_arguments} + } + + Supervisor.child_spec(default, restart: :transient) + end + + def start_link(arguments, gen_server_options \\ []) do + GenServer.start_link(__MODULE__, arguments, gen_server_options) + end + + @impl GenServer + def init(opts) when is_list(opts) do + Logger.metadata(fetcher: :withdrawal) + first_block = Application.get_env(:indexer, __MODULE__)[:first_block] + + if first_block |> Helper.parse_integer() |> is_integer() do + # withdrawals from all other blocks will be imported by realtime and catchup indexers + json_rpc_named_arguments = opts[:json_rpc_named_arguments] + + unless json_rpc_named_arguments do + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.init to allow for json_rpc calls when running." + end + + state = %__MODULE__{ + blocks_to_fetch: first_block |> Helper.parse_integer() |> missing_block_numbers(), + interval: opts[:interval] || @interval, + json_rpc_named_arguments: json_rpc_named_arguments, + max_batch_size: opts[:max_batch_size] || @batch_size, + max_concurrency: opts[:max_concurrency] || @concurrency + } + + Process.send_after(self(), :fetch_withdrawals, state.interval) + + {:ok, state} + else + Logger.warn("Please, specify the first block of the block range for #{__MODULE__}.") + :ignore + end + end + + @impl GenServer + def handle_info( + :fetch_withdrawals, + %__MODULE__{ + blocks_to_fetch: blocks_to_fetch, + interval: interval, + json_rpc_named_arguments: json_rpc_named_arguments, + max_batch_size: batch_size, + max_concurrency: concurrency + } = state + ) do + Logger.metadata(fetcher: :withdrawal) + + if Enum.empty?(blocks_to_fetch) do + Logger.info("Withdrawals from old blocks are fetched.") + {:stop, :normal, state} + else + new_blocks_to_fetch = + blocks_to_fetch + |> Stream.chunk_every(batch_size) + |> Task.async_stream( + &{EthereumJSONRPC.fetch_blocks_by_numbers(&1, json_rpc_named_arguments), &1}, + max_concurrency: concurrency, + timeout: :infinity, + zip_input_on_exit: true + ) + |> Enum.reduce([], &fetch_reducer/2) + + Process.send_after(self(), :fetch_withdrawals, interval) + + {:noreply, %__MODULE__{state | blocks_to_fetch: new_blocks_to_fetch}} + end + end + + def handle_info({ref, _result}, state) do + Process.demonitor(ref, [:flush]) + {:noreply, state} + end + + def handle_info( + {:DOWN, _ref, :process, _pid, reason}, + state + ) do + if reason === :normal do + {:noreply, state} + else + Logger.metadata(fetcher: :withdrawal) + Logger.error(fn -> "Withdrawals fetcher task exited due to #{inspect(reason)}." end) + {:noreply, state} + end + end + + defp fetch_reducer({:ok, {{:ok, %Blocks{withdrawals_params: withdrawals_params}}, block_numbers}}, acc) do + addresses = Addresses.extract_addresses(%{withdrawals: withdrawals_params}) + + case Chain.import(%{addresses: %{params: addresses}, withdrawals: %{params: withdrawals_params}}) do + {:ok, _} -> + acc + + {:error, reason} -> + Logger.error(inspect(reason) <> ". Retrying.") + [block_numbers | acc] |> List.flatten() + + {:error, step, failed_value, _changes_so_far} -> + Logger.error("failed to insert: " <> inspect(failed_value) <> ". Retrying.", step: step) + [block_numbers | acc] |> List.flatten() + end + end + + defp fetch_reducer({:ok, {{:error, reason}, block_numbers}}, acc) do + Logger.error("failed to fetch: " <> inspect(reason) <> ". Retrying.") + [block_numbers | acc] |> List.flatten() + end + + defp fetch_reducer({:exit, {block_numbers, reason}}, acc) do + Logger.error("failed to fetch: " <> inspect(reason) <> ". Retrying.") + [block_numbers | acc] |> List.flatten() + end + + defp missing_block_numbers(from) do + blocks = from |> Withdrawal.blocks_without_withdrawals_query() |> Repo.all() + Logger.debug("missing_block_numbers #{length(blocks)}") + blocks + end +end diff --git a/apps/indexer/lib/indexer/supervisor.ex b/apps/indexer/lib/indexer/supervisor.ex index b8d6fbcaa7..38b2e39f6d 100644 --- a/apps/indexer/lib/indexer/supervisor.ex +++ b/apps/indexer/lib/indexer/supervisor.ex @@ -30,7 +30,8 @@ defmodule Indexer.Supervisor do TokenBalance, TokenUpdater, TransactionAction, - UncleBlock + UncleBlock, + Withdrawal } alias Indexer.Temporary.{ @@ -143,7 +144,8 @@ defmodule Indexer.Supervisor do [ %{block_fetcher: block_fetcher, block_interval: block_interval, memory_monitor: memory_monitor}, [name: BlockCatchup.Supervisor] - ]} + ]}, + {Withdrawal.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]} ] |> List.flatten() diff --git a/apps/indexer/lib/indexer/transform/address_coin_balances.ex b/apps/indexer/lib/indexer/transform/address_coin_balances.ex index cd71508bc2..03319b0dec 100644 --- a/apps/indexer/lib/indexer/transform/address_coin_balances.ex +++ b/apps/indexer/lib/indexer/transform/address_coin_balances.ex @@ -50,6 +50,13 @@ defmodule Indexer.Transform.AddressCoinBalances do when is_list(block_second_degree_relations_params), do: initial + defp reducer({:withdrawals, withdrawals}, acc) when is_list(withdrawals) do + Enum.into(withdrawals, acc, fn %{address_hash: address_hash, block_number: block_number} + when is_binary(address_hash) and is_integer(block_number) -> + %{address_hash: address_hash, block_number: block_number} + end) + end + defp internal_transactions_params_reducer(%{block_number: block_number} = internal_transaction_params, acc) when is_integer(block_number) do case internal_transaction_params do diff --git a/apps/indexer/lib/indexer/transform/addresses.ex b/apps/indexer/lib/indexer/transform/addresses.ex index acedc9c126..5787cc62bb 100644 --- a/apps/indexer/lib/indexer/transform/addresses.ex +++ b/apps/indexer/lib/indexer/transform/addresses.ex @@ -133,6 +133,12 @@ defmodule Indexer.Transform.Addresses do %{from: :block_number, to: :fetched_coin_balance_block_number}, %{from: :address_hash, to: :hash} ] + ], + withdrawals: [ + [ + %{from: :block_number, to: :fetched_coin_balance_block_number}, + %{from: :address_hash, to: :hash} + ] ] } @@ -427,6 +433,12 @@ defmodule Indexer.Transform.Addresses do required(:address_hash) => String.t(), required(:block_number) => non_neg_integer() } + ], + optional(:withdrawals) => [ + %{ + required(:address_hash) => String.t(), + required(:block_number) => non_neg_integer() + } ] }) :: [params] def extract_addresses(fetched_data, options \\ []) when is_map(fetched_data) and is_list(options) do diff --git a/apps/indexer/test/indexer/fetcher/withdrawal_test.exs b/apps/indexer/test/indexer/fetcher/withdrawal_test.exs new file mode 100644 index 0000000000..cfe60e9e4a --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/withdrawal_test.exs @@ -0,0 +1,152 @@ +defmodule Indexer.Fetcher.WithdrawalTest do + use EthereumJSONRPC.Case + use Explorer.DataCase + + import Mox + import EthereumJSONRPC, only: [integer_to_quantity: 1] + + alias Explorer.Chain + alias Indexer.Fetcher.Withdrawal + + setup :verify_on_exit! + setup :set_mox_global + + setup do + initial_env = Application.get_all_env(:indexer) + on_exit(fn -> Application.put_all_env([{:indexer, initial_env}]) end) + end + + test "do not crash app when WITHDRAWALS_FIRST_BLOCK is undefined", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + Application.put_env(:indexer, Withdrawal.Supervisor, disabled?: "false") + Withdrawal.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + assert [{Indexer.Fetcher.Withdrawal, :undefined, :worker, [Indexer.Fetcher.Withdrawal]} | _] = + Withdrawal.Supervisor |> Supervisor.which_children() + end + + test "do not start when all old blocks are fetched", %{json_rpc_named_arguments: json_rpc_named_arguments} do + Application.put_env(:indexer, Withdrawal.Supervisor, disabled?: "false") + Withdrawal.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + Application.put_env(:indexer, Withdrawal, first_block: "0") + + assert [{Indexer.Fetcher.Withdrawal, :undefined, :worker, [Indexer.Fetcher.Withdrawal]} | _] = + Withdrawal.Supervisor |> Supervisor.which_children() + end + + test "stops when all old blocks are fetched", %{json_rpc_named_arguments: json_rpc_named_arguments} do + Application.put_env(:indexer, Withdrawal.Supervisor, disabled?: "false") + Application.put_env(:indexer, Withdrawal, first_block: "0") + + block_a = insert(:block) + block_b = insert(:block) + + block_a_number_string = integer_to_quantity(block_a.number) + block_b_number_string = integer_to_quantity(block_b.number) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 2, fn requests, _options -> + {:ok, + Enum.map(requests, fn + %{id: id, method: "eth_getBlockByNumber", params: [^block_a_number_string, true]} -> + %{ + id: id, + result: %{ + "author" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", + "difficulty" => "0x6bc767dd80781", + "extraData" => "0x5050594520737061726b706f6f6c2d6574682d7477", + "gasLimit" => "0x7a121d", + "gasUsed" => "0x79cbe9", + "hash" => to_string(block_a.hash), + "logsBloom" => + "0x044d42d008801488400e1809190200a80d06105bc0c4100b047895c0d518327048496108388040140010b8208006288102e206160e21052322440924002090c1c808a0817405ab238086d028211014058e949401012403210314896702d06880c815c3060a0f0809987c81044488292cc11d57882c912a808ca10471c84460460040000c0001012804022000a42106591881d34407420ba401e1c08a8d00a000a34c11821a80222818a4102152c8a0c044032080c6462644223104d618e0e544072008120104408205c60510542264808488220403000106281a0290404220112c10b080145028c8000300b18a2c8280701c882e702210b00410834840108084", + "miner" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", + "mixHash" => "0xda53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", + "nonce" => "0x0946e5f01fce12bc", + "number" => "0x708677", + "parentHash" => "0x62543e836e0ef7edfa9e38f26526092c4be97efdf5ba9e0f53a4b0b7d5bc930a", + "receiptsRoot" => "0xa7d2b82bd8526de11736c18bd5cc8cfe2692106c4364526f3310ad56d78669c4", + "sealFields" => [ + "0xa0da53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", + "0x880946e5f01fce12bc" + ], + "sha3Uncles" => "0x483a8a21a5825ad270f358b3ea56e060bbb8b3082d9a92ec8fa17a5c7e6fc1b6", + "size" => "0x544c", + "stateRoot" => "0x85daa9cd528004c1609d4cb3520fd958e85983bb4183124a4a9f7137fd39c691", + "timestamp" => "0x5c8bc76e", + "totalDifficulty" => "0x201a42c35142ae94458", + "transactions" => [], + "transactionsRoot" => "0xcd6c12fa43cd4e92ad5c0bf232b30488bbcbfe273c5b4af0366fced0767d54db", + "uncles" => [], + "withdrawals" => [ + %{ + "index" => "0x1", + "validatorIndex" => "0x80b", + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => "0x2c17a12dc00" + } + ] + } + } + + %{id: id, method: "eth_getBlockByNumber", params: [^block_b_number_string, true]} -> + %{ + id: id, + result: %{ + "author" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", + "difficulty" => "0x6bc767dd80781", + "extraData" => "0x5050594520737061726b706f6f6c2d6574682d7477", + "gasLimit" => "0x7a121d", + "gasUsed" => "0x79cbe9", + "hash" => to_string(block_b.hash), + "logsBloom" => + "0x044d42d008801488400e1809190200a80d06105bc0c4100b047895c0d518327048496108388040140010b8208006288102e206160e21052322440924002090c1c808a0817405ab238086d028211014058e949401012403210314896702d06880c815c3060a0f0809987c81044488292cc11d57882c912a808ca10471c84460460040000c0001012804022000a42106591881d34407420ba401e1c08a8d00a000a34c11821a80222818a4102152c8a0c044032080c6462644223104d618e0e544072008120104408205c60510542264808488220403000106281a0290404220112c10b080145028c8000300b18a2c8280701c882e702210b00410834840108084", + "miner" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", + "mixHash" => "0xda53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", + "nonce" => "0x0946e5f01fce12bc", + "number" => "0x708677", + "parentHash" => "0x62543e836e0ef7edfa9e38f26526092c4be97efdf5ba9e0f53a4b0b7d5bc930a", + "receiptsRoot" => "0xa7d2b82bd8526de11736c18bd5cc8cfe2692106c4364526f3310ad56d78669c4", + "sealFields" => [ + "0xa0da53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", + "0x880946e5f01fce12bc" + ], + "sha3Uncles" => "0x483a8a21a5825ad270f358b3ea56e060bbb8b3082d9a92ec8fa17a5c7e6fc1b6", + "size" => "0x544c", + "stateRoot" => "0x85daa9cd528004c1609d4cb3520fd958e85983bb4183124a4a9f7137fd39c691", + "timestamp" => "0x5c8bc76e", + "totalDifficulty" => "0x201a42c35142ae94458", + "transactions" => [], + "transactionsRoot" => "0xcd6c12fa43cd4e92ad5c0bf232b30488bbcbfe273c5b4af0366fced0767d54db", + "uncles" => [], + "withdrawals" => [ + %{ + "index" => "0x2", + "validatorIndex" => "0x80b", + "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", + "amount" => "0x2c17a12dc00" + } + ] + } + } + end)} + end) + + pid = Withdrawal.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + assert [{Indexer.Fetcher.Withdrawal, worker_pid, :worker, [Indexer.Fetcher.Withdrawal]} | _] = + Withdrawal.Supervisor |> Supervisor.which_children() + + assert is_pid(worker_pid) + + :timer.sleep(300) + + assert [{Indexer.Fetcher.Withdrawal, :undefined, :worker, [Indexer.Fetcher.Withdrawal]} | _] = + Withdrawal.Supervisor |> Supervisor.which_children() + + # Terminates the process so it finishes all Ecto processes. + GenServer.stop(pid) + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex new file mode 100644 index 0000000000..2f419efcc8 --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex @@ -0,0 +1,17 @@ +defmodule Indexer.Fetcher.Withdrawal.Supervisor.Case do + alias Indexer.Fetcher.Withdrawal + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + merged_fetcher_arguments = + Keyword.merge( + fetcher_arguments, + interval: 1, + max_batch_size: 1, + max_concurrency: 1 + ) + + [merged_fetcher_arguments] + |> Withdrawal.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/config/config.exs b/config/config.exs index 66c40f086e..554d3ae557 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,6 +34,7 @@ config :logger, {LoggerFileBackend, :reading_token_functions}, {LoggerFileBackend, :pending_transactions_to_refetch}, {LoggerFileBackend, :empty_blocks_to_refetch}, + {LoggerFileBackend, :withdrawal}, {LoggerFileBackend, :api}, {LoggerFileBackend, :block_import_timings}, {LoggerFileBackend, :account}, diff --git a/config/runtime.exs b/config/runtime.exs index b312d011b6..7e6c891cd3 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -495,6 +495,11 @@ config :indexer, Indexer.Fetcher.CoinBalance, batch_size: ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_BATCH_SIZE", 500), concurrency: ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_CONCURRENCY", 4) +config :indexer, Indexer.Fetcher.Withdrawal.Supervisor, + disabled?: System.get_env("INDEXER_DISABLE_WITHDRAWALS_FETCHER", "true") == "true" + +config :indexer, Indexer.Fetcher.Withdrawal, first_block: System.get_env("WITHDRAWALS_FIRST_BLOCK") + Code.require_file("#{config_env()}.exs", "config/runtime") for config <- "../apps/*/config/runtime/#{config_env()}.exs" |> Path.expand(__DIR__) |> Path.wildcard() do diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index f7ab96a631..0f69031e74 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -117,6 +117,8 @@ INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER=false # INDEXER_TX_ACTIONS_REINDEX_PROTOCOLS= # INDEXER_TX_ACTIONS_AAVE_V3_POOL_CONTRACT= # INDEXER_REALTIME_FETCHER_MAX_GAP= +# INDEXER_DISABLE_WITHDRAWALS_FETCHER= +# WITHDRAWALS_FIRST_BLOCK= # TOKEN_ID_MIGRATION_FIRST_BLOCK= # TOKEN_ID_MIGRATION_CONCURRENCY= # TOKEN_ID_MIGRATION_BATCH_SIZE= diff --git a/docker/Makefile b/docker/Makefile index 843cc65658..495e4dcef9 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -554,6 +554,11 @@ ifdef INDEXER_REALTIME_FETCHER_MAX_GAP endif ifdef INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE BLOCKSCOUT_CONTAINER_PARAMS += -e 'INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE=$(INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE)' +ifdef INDEXER_DISABLE_WITHDRAWALS_FETCHER + BLOCKSCOUT_CONTAINER_PARAMS += -e 'INDEXER_DISABLE_WITHDRAWALS_FETCHER=$(INDEXER_DISABLE_WITHDRAWALS_FETCHER)' +endif +ifdef WITHDRAWALS_FIRST_BLOCK + BLOCKSCOUT_CONTAINER_PARAMS += -e 'WITHDRAWALS_FIRST_BLOCK=$(WITHDRAWALS_FIRST_BLOCK)' endif ifdef TOKEN_ID_MIGRATION_FIRST_BLOCK BLOCKSCOUT_CONTAINER_PARAMS += -e 'TOKEN_ID_MIGRATION_FIRST_BLOCK=$(TOKEN_ID_MIGRATION_FIRST_BLOCK)'