Add withdrawals to eth json rpc app

Add withdrawals to explorer app

Add withdrawals to indexer

Add withdrawal tabs to address and block pages

Add withdrawals test to web

Add withdrawals test to eth json rpc

Add withdrawals test to explorer

Add withdrawal test to indexer

Update CHANGELOG.md

Update gettext

[no ci]

Update apps/indexer/config/prod.exs

[no ci]

Co-authored-by: nikitosing <32202610+nikitosing@users.noreply.github.com>

Fix review

Update CHANGELOG.md

Update CHANGELOG.md

Add withdrawal list page; Add withdrawal to APIv2

Add timestamp

Fix nikitosing review
pull/6694/head
sl1depengwyn 2 years ago committed by Maxim Filonov
parent b98b7822e0
commit 43604709ad
  1. 1
      CHANGELOG.md
  2. 1
      apps/block_scout_web/assets/js/pages/address.js
  3. 6
      apps/block_scout_web/lib/block_scout_web/api_router.ex
  4. 7
      apps/block_scout_web/lib/block_scout_web/chain.ex
  5. 121
      apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex
  6. 19
      apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex
  7. 21
      apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex
  8. 25
      apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex
  9. 6
      apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex
  10. 106
      apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex
  11. 39
      apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex
  12. 8
      apps/block_scout_web/lib/block_scout_web/templates/address/_tabs.html.eex
  13. 1
      apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_metatags.html.eex
  14. 25
      apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/_withdrawal.html.eex
  15. 66
      apps/block_scout_web/lib/block_scout_web/templates/address_withdrawal/index.html.eex
  16. 4
      apps/block_scout_web/lib/block_scout_web/templates/block/_number_link.html.eex
  17. 19
      apps/block_scout_web/lib/block_scout_web/templates/block/_tabs.html.eex
  18. 12
      apps/block_scout_web/lib/block_scout_web/templates/block_transaction/index.html.eex
  19. 1
      apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_metatags.html.eex
  20. 23
      apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/_withdrawal.html.eex
  21. 54
      apps/block_scout_web/lib/block_scout_web/templates/block_withdrawal/index.html.eex
  22. 3
      apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex
  23. 5
      apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex
  24. 8
      apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_metatags.html.eex
  25. 34
      apps/block_scout_web/lib/block_scout_web/templates/withdrawal/_withdrawal.html.eex
  26. 59
      apps/block_scout_web/lib/block_scout_web/templates/withdrawal/index.html.eex
  27. 3
      apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex
  28. 3
      apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex
  29. 6
      apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex
  30. 41
      apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex
  31. 3
      apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex
  32. 3
      apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex
  33. 11
      apps/block_scout_web/lib/block_scout_web/web_router.ex
  34. 123
      apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs
  35. 39
      apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs
  36. 77
      apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs
  37. 55
      apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs
  38. 139
      apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs
  39. 60
      apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs
  40. 11
      apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex
  41. 160
      apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex
  42. 118
      apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex
  43. 101
      apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex
  44. 67
      apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex
  45. 13
      apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs
  46. 5
      apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs
  47. 5
      apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs
  48. 65
      apps/explorer/lib/explorer/chain.ex
  49. 4
      apps/explorer/lib/explorer/chain/address.ex
  50. 4
      apps/explorer/lib/explorer/chain/block.ex
  51. 106
      apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex
  52. 3
      apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex
  53. 113
      apps/explorer/lib/explorer/chain/withdrawal.ex
  54. 3
      apps/explorer/lib/explorer/helper.ex
  55. 19
      apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs
  56. 82
      apps/explorer/test/explorer/chain/withdrawal_test.exs
  57. 26
      apps/explorer/test/support/factory.ex
  58. 2
      apps/indexer/README.md
  59. 5
      apps/indexer/config/dev.exs
  60. 6
      apps/indexer/config/prod.exs
  61. 10
      apps/indexer/lib/indexer/block/fetcher.ex
  62. 159
      apps/indexer/lib/indexer/fetcher/withdrawal.ex
  63. 6
      apps/indexer/lib/indexer/supervisor.ex
  64. 7
      apps/indexer/lib/indexer/transform/address_coin_balances.ex
  65. 12
      apps/indexer/lib/indexer/transform/addresses.ex
  66. 152
      apps/indexer/test/indexer/fetcher/withdrawal_test.exs
  67. 17
      apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex
  68. 1
      config/config.exs
  69. 5
      config/runtime.exs
  70. 2
      docker-compose/envs/common-blockscout.env
  71. 5
      docker/Makefile

@ -4,6 +4,7 @@
### Features ### 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 - [#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 - [#7393](https://github.com/blockscout/blockscout/pull/7393) - Realtime fetcher max gap

@ -251,6 +251,7 @@ if ($addressDetailsPage.length) {
const shouldScroll = pathParts.includes('transactions') || const shouldScroll = pathParts.includes('transactions') ||
pathParts.includes('token-transfers') || pathParts.includes('token-transfers') ||
pathParts.includes('tokens') || pathParts.includes('tokens') ||
pathParts.includes('withdrawals') ||
pathParts.includes('internal-transactions') || pathParts.includes('internal-transactions') ||
pathParts.includes('coin-balances') || pathParts.includes('coin-balances') ||
pathParts.includes('logs') || pathParts.includes('logs') ||

@ -141,6 +141,7 @@ defmodule BlockScoutWeb.ApiRouter do
get("/", V2.BlockController, :blocks) get("/", V2.BlockController, :blocks)
get("/:block_hash_or_number", V2.BlockController, :block) get("/:block_hash_or_number", V2.BlockController, :block)
get("/:block_hash_or_number/transactions", V2.BlockController, :transactions) get("/:block_hash_or_number/transactions", V2.BlockController, :transactions)
get("/:block_hash_or_number/withdrawals", V2.BlockController, :withdrawals)
end end
scope "/addresses" do scope "/addresses" do
@ -156,6 +157,7 @@ defmodule BlockScoutWeb.ApiRouter do
get("/:address_hash/blocks-validated", V2.AddressController, :blocks_validated) 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", V2.AddressController, :coin_balance_history)
get("/:address_hash/coin-balance-history-by-day", V2.AddressController, :coin_balance_history_by_day) get("/:address_hash/coin-balance-history-by-day", V2.AddressController, :coin_balance_history_by_day)
get("/:address_hash/withdrawals", V2.AddressController, :withdrawals)
end end
scope "/tokens" do scope "/tokens" do
@ -186,6 +188,10 @@ defmodule BlockScoutWeb.ApiRouter do
get("/market", V2.StatsController, :market_chart) get("/market", V2.StatsController, :market_chart)
end end
end end
scope "/withdrawals" do
get("/", V2.WithdrawalController, :withdrawals_list)
end
end end
scope "/v1", as: :api_v1 do scope "/v1", as: :api_v1 do

@ -30,7 +30,8 @@ defmodule BlockScoutWeb.Chain do
Token.Instance, Token.Instance,
TokenTransfer, TokenTransfer,
Transaction, Transaction,
Wei Wei,
Withdrawal
} }
alias Explorer.PagingOptions alias Explorer.PagingOptions
@ -437,6 +438,10 @@ defmodule BlockScoutWeb.Chain do
%{"smart_contract_id" => smart_contract.id} %{"smart_contract_id" => smart_contract.id}
end end
defp paging_params(%Withdrawal{index: index}) do
%{"index" => index}
end
# clause for search results pagination # clause for search results pagination
defp paging_params(%{ defp paging_params(%{
address_hash: address_hash, address_hash: address_hash,

@ -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

@ -14,7 +14,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do
only: [delete_parameters_from_next_page_params: 1, token_transfers_types_options: 1] only: [delete_parameters_from_next_page_params: 1, token_transfers_types_options: 1]
alias BlockScoutWeb.AccessHelper alias BlockScoutWeb.AccessHelper
alias BlockScoutWeb.API.V2.{BlockView, TransactionView} alias BlockScoutWeb.API.V2.{BlockView, TransactionView, WithdrawalView}
alias Explorer.ExchangeRates.Token alias Explorer.ExchangeRates.Token
alias Explorer.{Chain, Market} alias Explorer.{Chain, Market}
alias Indexer.Fetcher.{CoinBalanceOnDemand, TokenBalanceOnDemand} alias Indexer.Fetcher.{CoinBalanceOnDemand, TokenBalanceOnDemand}
@ -370,6 +370,23 @@ defmodule BlockScoutWeb.API.V2.AddressController do
end end
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 def addresses_list(conn, params) do
{addresses, next_page} = {addresses, next_page} =
params params

@ -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] 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 alias Explorer.Chain
@transaction_necessity_by_association [ @transaction_necessity_by_association [
@ -106,4 +106,23 @@ defmodule BlockScoutWeb.API.V2.BlockController do
|> render(:transactions, %{transactions: transactions, next_page_params: next_page_params}) |> render(:transactions, %{transactions: transactions, next_page_params: next_page_params})
end end
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 end

@ -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

@ -139,7 +139,7 @@ defmodule BlockScoutWeb.BlockTransactionController do
end end
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 case parse_block_hash_or_number_param(param) do
{:ok, :number, number} -> {:ok, :number, number} ->
number_to_block(number, options) number_to_block(number, options)
@ -152,9 +152,9 @@ defmodule BlockScoutWeb.BlockTransactionController do
end end
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 case Chain.max_consensus_block_number() do
{:ok, max_consensus_block_number} -> {:ok, max_consensus_block_number} ->
{block_number, _} = Integer.parse(block_hash_or_number) {block_number, _} = Integer.parse(block_hash_or_number)

@ -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

@ -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

@ -24,6 +24,14 @@
"data-test": "tokens_tab_link" "data-test": "tokens_tab_link"
) %> ) %>
<% end %> <% 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( <%= link(
gettext("Internal Transactions"), gettext("Internal Transactions"),
class: "card-tab #{tab_status("internal-transactions", @conn.request_path)}", class: "card-tab #{tab_status("internal-transactions", @conn.request_path)}",

@ -0,0 +1 @@
<%= render BlockScoutWeb.AddressView, "_metatags.html", conn: @conn, address: @address %>

@ -0,0 +1,25 @@
<tr data-identifier-hash="<%= @withdrawal.index %>">
<!-- Index -->
<td class="stakes-td">
<span><%= @withdrawal.index %></span>
</td>
<!-- Validator Index -->
<td class="stakes-td">
<span><%= @withdrawal.validator_index %></span>
</td>
<!-- Block -->
<td class="stakes-td">
<%= render BlockScoutWeb.BlockView,
"_number_link.html",
block: @withdrawal.block
%>
</td>
<!-- Age -->
<td class="stakes-td">
<span data-from-now="<%= @withdrawal.block.timestamp %>"></span>
</td>
<!-- Amount -->
<td class="stakes-td">
<span><%= format_wei_value(@withdrawal.amount, :ether) %></span>
</td>
</tr>

@ -0,0 +1,66 @@
<section class="container">
<% 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 %>
<section data-page="address-withdrawals" id="withdrawals">
<div class="card">
<%= render BlockScoutWeb.AddressView, "_tabs.html", address: @address, is_proxy: is_proxy, conn: @conn %>
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>">
<div class="clearfix">
<h2 class="card-title float-left"><%= gettext "Withdrawals" %></h2>
<div class="top-pagination-outer-container float-right">
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %>
</div>
</div>
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;">
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span>
</button>
<div class="addresses-table-container">
<div class="stakes-table-container">
<table>
<thead>
<tr>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Index" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Validator index" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Block" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Age" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Amount" %></div>
</th>
</tr>
</thead>
<tbody data-items data-selector="address-withdrawals">
<%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 5 %>
</tbody>
</table>
</div>
</div>
<div data-empty-response-message style="display: none;">
<div class="tile tile-muted text-center" data-selector="empty-withdrawals-list">
<%= gettext "There are no withdrawals for this address." %>
</div>
</div>
<div class="top-pagination-outer-container float-right">
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %>
</div>
</div>
</div>
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/address.js") %>"></script>
</section>
</section>

@ -0,0 +1,4 @@
<%= link(
to_string(@block.number),
to: block_path(BlockScoutWeb.Endpoint, :show, @block)
) %>

@ -0,0 +1,19 @@
<div class="card-tabs js-card-tabs">
<%=
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 %>
</div>

@ -4,16 +4,8 @@
<section> <section>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-tabs js-card-tabs"> <%= render BlockScoutWeb.BlockView, "_tabs.html", assigns %>
<%=
link(
gettext("Transactions"),
class: "card-tab #{tab_status("transactions", @conn.request_path)}",
to: block_transaction_path(@conn, :index, @conn.params["block_hash_or_number"])
)
%>
</div>
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>" id="txs"> <div class="card-body" data-async-load data-async-listing="<%= @current_path %>" id="txs">
<%= 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, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %>

@ -0,0 +1 @@
<%= render BlockScoutWeb.BlockView, "_metatags.html", conn: @conn, block: @block %>

@ -0,0 +1,23 @@
<tr data-identifier-hash="<%= @withdrawal.index %>">
<!-- Index -->
<td class="stakes-td">
<span><%= @withdrawal.index %></span>
</td>
<!-- Validator Index -->
<td class="stakes-td">
<span><%= @withdrawal.validator_index %></span>
</td>
<!-- To -->
<td class="stakes-td">
<%= render BlockScoutWeb.AddressView,
"_link.html",
address: @withdrawal.address,
contract: BlockScoutWeb.AddressView.contract?(@withdrawal.address),
use_custom_tooltip: false
%>
</td>
<!-- Amount -->
<td class="stakes-td">
<span><%= format_wei_value(@withdrawal.amount, :ether) %></span>
</td>
</tr>

@ -0,0 +1,54 @@
<section class="container">
<%= render BlockScoutWeb.BlockView, "overview.html", assigns %>
<section>
<div class="card mb-3">
<%= render BlockScoutWeb.BlockView, "_tabs.html", assigns %>
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>" id="withdrawals">
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %>
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;">
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span>
</button>
<div class="addresses-table-container">
<div class="stakes-table-container">
<table>
<thead>
<tr>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Index" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Validator index" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "To" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Amount" %></div>
</th>
</tr>
</thead>
<tbody data-items data-selector="block-withdrawals">
<%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 4 %>
</tbody>
</table>
</div>
</div>
<div data-empty-response-message style="display: none;">
<div class="tile tile-muted text-center" data-selector="empty-withdrawals-list">
<%= gettext "There are no withdrawals for this block." %>
</div>
</div>
<%= 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 %>
</div>
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/async-listing-load.js") %>"></script>
</section>
</section>

@ -67,6 +67,9 @@
<% end %> <% end %>
<%= link(gettext("Verified contracts"), <%= link(gettext("Verified contracts"),
to: verified_contracts_path(@conn, :index), 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)}")%> class: "dropdown-item #{tab_status("reorgs", @conn.request_path)}")%>
</div> </div>
</li> </li>

@ -103,6 +103,7 @@
@view_module != Elixir.BlockScoutWeb.ChainView && @view_module != Elixir.BlockScoutWeb.ChainView &&
@view_module != Elixir.BlockScoutWeb.BlockView && @view_module != Elixir.BlockScoutWeb.BlockView &&
@view_module != Elixir.BlockScoutWeb.BlockTransactionView && @view_module != Elixir.BlockScoutWeb.BlockTransactionView &&
@view_module != Elixir.BlockScoutWeb.BlockWithdrawalView &&
@view_module != Elixir.BlockScoutWeb.AddressView && @view_module != Elixir.BlockScoutWeb.AddressView &&
@view_module != Elixir.BlockScoutWeb.TokensView && @view_module != Elixir.BlockScoutWeb.TokensView &&
@view_module != Elixir.BlockScoutWeb.TransactionView && @view_module != Elixir.BlockScoutWeb.TransactionView &&
@ -115,6 +116,7 @@
@view_module != Elixir.BlockScoutWeb.AddressTransactionView && @view_module != Elixir.BlockScoutWeb.AddressTransactionView &&
@view_module != Elixir.BlockScoutWeb.AddressTokenTransferView && @view_module != Elixir.BlockScoutWeb.AddressTokenTransferView &&
@view_module != Elixir.BlockScoutWeb.AddressTokenView && @view_module != Elixir.BlockScoutWeb.AddressTokenView &&
@view_module != Elixir.BlockScoutWeb.AddressWithdrawalView &&
@view_module != Elixir.BlockScoutWeb.AddressInternalTransactionView && @view_module != Elixir.BlockScoutWeb.AddressInternalTransactionView &&
@view_module != Elixir.BlockScoutWeb.AddressCoinBalanceView && @view_module != Elixir.BlockScoutWeb.AddressCoinBalanceView &&
@view_module != Elixir.BlockScoutWeb.AddressLogsView && @view_module != Elixir.BlockScoutWeb.AddressLogsView &&
@ -142,7 +144,8 @@
@view_module != Elixir.BlockScoutWeb.SearchView && @view_module != Elixir.BlockScoutWeb.SearchView &&
@view_module != Elixir.BlockScoutWeb.AddressContractVerificationViaStandardJsonInputView && @view_module != Elixir.BlockScoutWeb.AddressContractVerificationViaStandardJsonInputView &&
@view_module != Elixir.BlockScoutWeb.AddressContractVerificationViaMultiPartFilesView && @view_module != Elixir.BlockScoutWeb.AddressContractVerificationViaMultiPartFilesView &&
@view_module != Elixir.BlockScoutWeb.StakesView @view_module != Elixir.BlockScoutWeb.StakesView &&
@view_module != Elixir.BlockScoutWeb.WithdrawalView
) do %> ) do %>
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/app.js") %>"></script> <script defer data-cfasync="false" src="<%= static_path(@conn, "/js/app.js") %>"></script>
<% end %> <% end %>

@ -0,0 +1,8 @@
<title>
<%= gettext(
"Beacon chain withdrawals - %{subnetwork} Explorer",
subnetwork: BlockScoutWeb.LayoutView.subnetwork_title()
) %>
</title>
<meta name="keywords" content="<%= gettext "Beacon chain, Withdrawals, %{subnetwork}, %{coin}", subnetwork: BlockScoutWeb.LayoutView.subnetwork_title(), coin: Explorer.coin() %>">
<meta name="description" content="<%= gettext "View the beacon chain withdrawals on %{subnetwork}", subnetwork: BlockScoutWeb.LayoutView.subnetwork_title() %>">

@ -0,0 +1,34 @@
<tr data-identifier-hash="<%= @withdrawal.index %>">
<!-- Index -->
<td class="stakes-td">
<span><%= @withdrawal.index %></span>
</td>
<!-- Validator Index -->
<td class="stakes-td">
<span><%= @withdrawal.validator_index %></span>
</td>
<!-- Block -->
<td class="stakes-td">
<%= render BlockScoutWeb.BlockView,
"_number_link.html",
block: @withdrawal.block
%>
</td>
<!-- To -->
<td class="stakes-td">
<%= render BlockScoutWeb.AddressView,
"_link.html",
address: @withdrawal.address,
contract: BlockScoutWeb.AddressView.contract?(@withdrawal.address),
use_custom_tooltip: false
%>
</td>
<!-- Age -->
<td class="stakes-td">
<span data-from-now="<%= @withdrawal.block.timestamp %>"></span>
</td>
<!-- Amount -->
<td class="stakes-td">
<span><%= format_wei_value(@withdrawal.amount, :ether) %></span>
</td>
</tr>

@ -0,0 +1,59 @@
<section class="container" data-page="verified-contracts-list">
<%= render BlockScoutWeb.Advertisement.TextAdView, "index.html", conn: @conn %>
<div class="card">
<div id="withdrawals-list" class="card-body" data-async-load data-async-listing="<%= @current_path %>">
<h1 class="card-title list-title-description d-inline-block"><%= gettext "Withdrawals" %></h1>
<div class="float-right">
<%= 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 %>
</div>
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;">
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span>
</button>
<div class="addresses-table-container">
<div class="stakes-table-container">
<table>
<thead>
<tr>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Index" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Validator index" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Block" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "To" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Age" %></div>
</th>
<th class="stakes-table-th">
<div class="stakes-table-th-content"><%= gettext "Amount" %></div>
</th>
</tr>
</thead>
<tbody data-items data-selector="address-withdrawals">
<%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 6 %>
</tbody>
</table>
</div>
</div>
<div data-empty-response-message style="display: none;">
<br>
<div class="tile tile-muted text-center">
<span data-selector="empty-verified-contracts-list">
<%= gettext "There are no withdrawals." %>
</span>
</div>
</div>
<%= 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 %>
</div>
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/async-listing-load.js") %>"></script>
</section>

@ -0,0 +1,3 @@
defmodule BlockScoutWeb.AddressWithdrawalView do
use BlockScoutWeb, :view
end

@ -107,7 +107,8 @@ defmodule BlockScoutWeb.API.V2.AddressView do
"has_logs" => Chain.check_if_logs_at_address(address.hash, @api_true), "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_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), "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 end

@ -7,6 +7,8 @@ defmodule BlockScoutWeb.API.V2.BlockView do
alias Explorer.Chain.Block alias Explorer.Chain.Block
alias Explorer.Counters.BlockPriorityFeeCounter alias Explorer.Counters.BlockPriorityFeeCounter
@api_true [api?: true]
def render("message.json", assigns) do def render("message.json", assigns) do
ApiView.render("message.json", assigns) ApiView.render("message.json", assigns)
end end
@ -58,7 +60,9 @@ defmodule BlockScoutWeb.API.V2.BlockView do
"gas_used_percentage" => gas_used_percentage(block), "gas_used_percentage" => gas_used_percentage(block),
"burnt_fees_percentage" => burnt_fees_percentage(burned_fee, tx_fees), "burnt_fees_percentage" => burnt_fees_percentage(burned_fee, tx_fees),
"type" => block |> BlockView.block_type() |> String.downcase(), "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 end

@ -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

@ -0,0 +1,3 @@
defmodule BlockScoutWeb.BlockWithdrawalView do
use BlockScoutWeb, :view
end

@ -0,0 +1,3 @@
defmodule BlockScoutWeb.WithdrawalView do
use BlockScoutWeb, :view
end

@ -104,6 +104,7 @@ defmodule BlockScoutWeb.WebRouter do
resources "/block", BlockController, only: [:show], param: "hash_or_number" do resources "/block", BlockController, only: [:show], param: "hash_or_number" do
resources("/transactions", BlockTransactionController, only: [:index], as: :transaction) resources("/transactions", BlockTransactionController, only: [:index], as: :transaction)
resources("/withdrawals", BlockWithdrawalController, only: [:index], as: :withdrawal)
end end
resources("/blocks", BlockController, as: :blocks, only: [:index]) resources("/blocks", BlockController, as: :blocks, only: [:index])
@ -113,6 +114,7 @@ defmodule BlockScoutWeb.WebRouter do
only: [:show], only: [:show],
param: "hash_or_number" do param: "hash_or_number" do
resources("/transactions", BlockTransactionController, only: [:index], as: :transaction) resources("/transactions", BlockTransactionController, only: [:index], as: :transaction)
resources("/withdrawals", BlockWithdrawalController, only: [:index], as: :withdrawal)
end end
get("/reorgs", BlockController, :reorg, as: :reorg) get("/reorgs", BlockController, :reorg, as: :reorg)
@ -125,6 +127,8 @@ defmodule BlockScoutWeb.WebRouter do
resources("/verified-contracts", VerifiedContractsController, only: [:index]) resources("/verified-contracts", VerifiedContractsController, only: [:index])
resources("/withdrawals", WithdrawalController, only: [:index])
get("/txs", TransactionController, :index) get("/txs", TransactionController, :index)
resources "/tx", TransactionController, only: [:show] do resources "/tx", TransactionController, only: [:show] do
@ -274,6 +278,13 @@ defmodule BlockScoutWeb.WebRouter do
as: :token_transfers as: :token_transfers
) )
resources(
"/withdrawals",
AddressWithdrawalController,
only: [:index],
as: :withdrawal
)
resources("/tokens", AddressTokenController, only: [:index], as: :token) do resources("/tokens", AddressTokenController, only: [:index], as: :token) do
resources( resources(
"/token-transfers", "/token-transfers",

@ -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

@ -12,7 +12,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do
Log, Log,
Token, Token,
TokenTransfer, TokenTransfer,
Transaction Transaction,
Withdrawal
} }
alias Explorer.Account.WatchlistAddress alias Explorer.Account.WatchlistAddress
@ -65,7 +66,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do
"has_logs" => false, "has_logs" => false,
"has_tokens" => false, "has_tokens" => false,
"has_token_transfers" => 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)}") request = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}")
@ -1590,6 +1592,35 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do
end end
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 describe "/addresses" do
test "get empty list", %{conn: conn} do test "get empty list", %{conn: conn} do
request = get(conn, "/api/v2/addresses") 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"] assert to_string(log.transaction_hash) == json["tx_hash"]
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 defp check_paginated_response(first_page_resp, second_page_resp, list) do
assert Enum.count(first_page_resp["items"]) == 50 assert Enum.count(first_page_resp["items"]) == 50
assert first_page_resp["next_page_params"] != nil assert first_page_resp["next_page_params"] != nil

@ -1,7 +1,7 @@
defmodule BlockScoutWeb.API.V2.BlockControllerTest do defmodule BlockScoutWeb.API.V2.BlockControllerTest do
use BlockScoutWeb.ConnCase use BlockScoutWeb.ConnCase
alias Explorer.Chain.{Address, Block, Transaction} alias Explorer.Chain.{Address, Block, Transaction, Withdrawal}
setup do setup do
Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id())
@ -311,6 +311,77 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do
end end
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 defp compare_item(%Block{} = block, json) do
assert to_string(block.hash) == json["hash"] assert to_string(block.hash) == json["hash"]
assert block.number == json["height"] 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"] assert Address.checksum(transaction.to_address_hash) == json["to"]["hash"]
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 defp check_paginated_response(first_page_resp, second_page_resp, list) do
assert Enum.count(first_page_resp["items"]) == 50 assert Enum.count(first_page_resp["items"]) == 50
assert first_page_resp["next_page_params"] != nil assert first_page_resp["next_page_params"] != nil

@ -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

@ -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

@ -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

@ -261,6 +261,17 @@ defmodule EthereumJSONRPC do
|> fetch_blocks_by_params(&Block.ByNumber.request/1, json_rpc_named_arguments) |> fetch_blocks_by_params(&Block.ByNumber.request/1, json_rpc_named_arguments)
end 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 """ @doc """
Fetches uncle blocks by nephew hashes and indices. Fetches uncle blocks by nephew hashes and indices.
""" """

@ -6,7 +6,7 @@ defmodule EthereumJSONRPC.Block do
import EthereumJSONRPC, only: [quantity_to_integer: 1, timestamp_to_datetime: 1] 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 elixir :: %{String.t() => non_neg_integer | DateTime.t() | String.t() | nil}
@type params :: %{ @type params :: %{
@ -29,7 +29,8 @@ defmodule EthereumJSONRPC.Block do
total_difficulty: non_neg_integer(), total_difficulty: non_neg_integer(),
transactions_root: EthereumJSONRPC.hash(), transactions_root: EthereumJSONRPC.hash(),
uncles: [EthereumJSONRPC.hash()], uncles: [EthereumJSONRPC.hash()],
base_fee_per_gas: non_neg_integer() base_fee_per_gas: non_neg_integer(),
withdrawals_root: EthereumJSONRPC.hash()
} }
@typedoc """ @typedoc """
@ -67,6 +68,7 @@ defmodule EthereumJSONRPC.Block do
[uncles](https://bitcoin.stackexchange.com/questions/39329/in-ethereum-what-is-an-uncle-block) [uncles](https://bitcoin.stackexchange.com/questions/39329/in-ethereum-what-is-an-uncle-block)
`t:EthereumJSONRPC.hash/0`. `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) * `"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} @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}"), timestamp: Timex.parse!("2017-12-15T21:03:30Z", "{ISO:Extended:Z}"),
total_difficulty: 340282366920938463463374607431465668165, total_difficulty: 340282366920938463463374607431465668165,
transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
uncles: [] uncles: [],
withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"
} }
[Geth] `elixir` can be converted to params [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}"), timestamp: Timex.parse!("2015-07-30T15:32:07Z", "{ISO:Extended:Z}"),
total_difficulty: 1039309006117, total_difficulty: 1039309006117,
transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
uncles: [] uncles: [],
withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"
} }
""" """
@ -235,7 +239,9 @@ defmodule EthereumJSONRPC.Block do
total_difficulty: total_difficulty, total_difficulty: total_difficulty,
transactions_root: transactions_root, transactions_root: transactions_root,
uncles: uncles, 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 end
@ -279,7 +285,9 @@ defmodule EthereumJSONRPC.Block do
timestamp: timestamp, timestamp: timestamp,
transactions_root: transactions_root, transactions_root: transactions_root,
uncles: uncles, 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 end
@ -323,7 +331,9 @@ defmodule EthereumJSONRPC.Block do
timestamp: timestamp, timestamp: timestamp,
total_difficulty: total_difficulty, total_difficulty: total_difficulty,
transactions_root: transactions_root, transactions_root: transactions_root,
uncles: uncles uncles: uncles,
withdrawals_root:
Map.get(elixir, "withdrawalsRoot", "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421")
} }
end end
@ -366,7 +376,9 @@ defmodule EthereumJSONRPC.Block do
state_root: state_root, state_root: state_root,
timestamp: timestamp, timestamp: timestamp,
transactions_root: transactions_root, transactions_root: transactions_root,
uncles: uncles uncles: uncles,
withdrawals_root:
Map.get(elixir, "withdrawalsRoot", "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421")
} }
end end
@ -506,6 +518,73 @@ defmodule EthereumJSONRPC.Block do
|> Enum.map(fn {uncle_hash, index} -> %{"hash" => uncle_hash, "nephewHash" => nephew_hash, "index" => index} end) |> Enum.map(fn {uncle_hash, index} -> %{"hash" => uncle_hash, "nephewHash" => nephew_hash, "index" => index} end)
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 """ @doc """
Decodes the stringly typed numerical fields to `t:non_neg_integer/0` and the timestamps to `t:DateTime.t/0` 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", ...> "totalDifficulty" => "0x2ffffffffffffffffffffffffedf78e41",
...> "transactions" => [], ...> "transactions" => [],
...> "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", ...> "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, "totalDifficulty" => 1020847100762815390390123822295002091073,
"transactions" => [], "transactions" => [],
"transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", "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 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 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 when key in ~w(difficulty gasLimit gasUsed minimumGasPrice baseFeePerGas number size cumulativeDifficulty totalDifficulty paidFees) and
not is_nil(quantity) do not is_nil(quantity) do
{key, quantity_to_integer(quantity)} {key, quantity_to_integer(quantity)}
end end
# Size and totalDifficulty may be `nil` for uncle blocks # 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} {key, nil}
end end
# double check that no new keys are being missed by requiring explicit match for passthrough # 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 # `t:EthereumJSONRPC.address/0` and `t:EthereumJSONRPC.hash/0` pass through as `Explorer.Chain` can verify correct
# hash format # 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 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 do: entry
defp entry_to_elixir({"timestamp" = key, timestamp}) do defp entry_to_elixir({"timestamp" = key, timestamp}, _block) do
{key, timestamp_to_datetime(timestamp)} {key, timestamp_to_datetime(timestamp)}
end end
defp entry_to_elixir({"transactions" = key, transactions}) do defp entry_to_elixir({"transactions" = key, transactions}, _block) do
{key, Transactions.to_elixir(transactions)} {key, Transactions.to_elixir(transactions)}
end 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 # Arbitrum fields
defp entry_to_elixir({"l1BlockNumber", _}) do defp entry_to_elixir({"l1BlockNumber", _}, _block) do
{:ignore, :ignore} {:ignore, :ignore}
end end
@ -609,7 +731,7 @@ defmodule EthereumJSONRPC.Block do
# blockExtraData extDataHash - Avalanche https://github.com/blockscout/blockscout/pull/5348 # blockExtraData extDataHash - Avalanche https://github.com/blockscout/blockscout/pull/5348
# vrf vrfProof - Harmony # vrf vrfProof - Harmony
# ... # ...
defp entry_to_elixir({_, _}) do defp entry_to_elixir({_, _}, _block) do
{:ignore, :ignore} {:ignore, :ignore}
end end
end end

@ -4,7 +4,7 @@ defmodule EthereumJSONRPC.Blocks do
and [`eth_getBlockByNumber`](https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_getblockbynumber) from batch requests. 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 elixir :: [Block.elixir()]
@type params :: [Block.params()] @type params :: [Block.params()]
@ -12,12 +12,14 @@ defmodule EthereumJSONRPC.Blocks do
blocks_params: [map()], blocks_params: [map()],
block_second_degree_relations_params: [map()], block_second_degree_relations_params: [map()],
transactions_params: [map()], transactions_params: [map()],
withdrawals_params: Withdrawals.params(),
errors: [Transport.error()] errors: [Transport.error()]
} }
defstruct blocks_params: [], defstruct blocks_params: [],
block_second_degree_relations_params: [], block_second_degree_relations_params: [],
transactions_params: [], transactions_params: [],
withdrawals_params: [],
errors: [] errors: []
def requests(id_to_params, request) when is_map(id_to_params) and is_function(request, 1) do 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_uncles = elixir_to_uncles(elixir_blocks)
elixir_transactions = elixir_to_transactions(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) block_second_degree_relations_params = Uncles.elixir_to_params(elixir_uncles)
transactions_params = Transactions.elixir_to_params(elixir_transactions) transactions_params = Transactions.elixir_to_params(elixir_transactions)
withdrawals_params = Withdrawals.elixir_to_params(elixir_withdrawals)
blocks_params = elixir_to_params(elixir_blocks) blocks_params = elixir_to_params(elixir_blocks)
%__MODULE__{ %__MODULE__{
errors: errors, errors: errors,
blocks_params: blocks_params, blocks_params: blocks_params,
block_second_degree_relations_params: block_second_degree_relations_params, block_second_degree_relations_params: block_second_degree_relations_params,
transactions_params: transactions_params transactions_params: transactions_params,
withdrawals_params: withdrawals_params
} }
end end
@ -110,7 +115,8 @@ defmodule EthereumJSONRPC.Blocks do
timestamp: Timex.parse!("1970-01-01T00:00:00Z", "{ISO:Extended:Z}"), timestamp: Timex.parse!("1970-01-01T00:00:00Z", "{ISO:Extended:Z}"),
total_difficulty: 131072, total_difficulty: 131072,
transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", 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) Enum.flat_map(elixir, &Block.elixir_to_uncles/1)
end 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 """ @doc """
Decodes the stringly typed numerical fields to `t:non_neg_integer/0` and the timestamps to `t:DateTime.t/0` 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", ...> "totalDifficulty" => "0x20000",
...> "transactions" => [], ...> "transactions" => [],
...> "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", ...> "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, "totalDifficulty" => 131072,
"transactions" => [], "transactions" => [],
"transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", "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"
} }
] ]
""" """

@ -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

@ -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

@ -52,7 +52,8 @@ defmodule EthereumJSONRPC.BlockTest do
timestamp: Timex.parse!("2015-07-30T15:32:07Z", "{ISO:Extended:Z}"), timestamp: Timex.parse!("2015-07-30T15:32:07Z", "{ISO:Extended:Z}"),
total_difficulty: nil, total_difficulty: nil,
transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
uncles: [] uncles: [],
withdrawals_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"
} }
end end
end end
@ -62,4 +63,14 @@ defmodule EthereumJSONRPC.BlockTest do
assert Block.elixir_to_transactions(%{}) == [] assert Block.elixir_to_transactions(%{}) == []
end end
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 end

@ -0,0 +1,5 @@
defmodule EthereumJSONRPC.WithdrawalTest do
use ExUnit.Case, async: true
doctest EthereumJSONRPC.Withdrawal
end

@ -0,0 +1,5 @@
defmodule EthereumJSONRPC.WithdrawalsTest do
use ExUnit.Case, async: true
doctest EthereumJSONRPC.Withdrawals
end

@ -61,7 +61,8 @@ defmodule Explorer.Chain do
Token.Instance, Token.Instance,
TokenTransfer, TokenTransfer,
Transaction, Transaction,
Wei Wei,
Withdrawal
} }
alias Explorer.Chain.Block.{EmissionReward, Reward} alias Explorer.Chain.Block.{EmissionReward, Reward}
@ -615,6 +616,21 @@ defmodule Explorer.Chain do
|> select_repo(options).all() |> select_repo(options).all()
end 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 """ @doc """
address_hash_to_token_transfers_including_contract/2 function returns token transfers on address (to/from/contract). 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. It is used by CSV export of token transfers button.
@ -990,6 +1006,21 @@ defmodule Explorer.Chain do
)).() )).()
end 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 """ @doc """
Finds sum of gas_used for new (EIP-1559) txs belongs to block 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) Repo.aggregate(query, :count, :hash)
end 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() @spec address_to_incoming_transaction_count(Hash.Address.t()) :: non_neg_integer()
def address_to_incoming_transaction_count(address_hash) do def address_to_incoming_transaction_count(address_hash) do
to_address_query = to_address_query =
@ -2665,6 +2703,13 @@ defmodule Explorer.Chain do
) )
end 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 """ @doc """
Counts all of the block validations and groups by the `miner_hash`. 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) |> limit(^paging_options.page_size)
end 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, [], %{}], defp handle_random_access_paging_options(query, empty_options) when empty_options in [nil, [], %{}],
do: limit(query, ^(@default_page_size + 1)) 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 = 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)} {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
end end

@ -20,7 +20,8 @@ defmodule Explorer.Chain.Address do
SmartContractAdditionalSource, SmartContractAdditionalSource,
Token, Token,
Transaction, Transaction,
Wei Wei,
Withdrawal
} }
alias Explorer.Chain.Cache.NetVersion 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(:names, Address.Name, foreign_key: :address_hash)
has_many(:decompiled_smart_contracts, DecompiledSmartContract, 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(:smart_contract_additional_sources, SmartContractAdditionalSource, foreign_key: :address_hash)
has_many(:withdrawals, Withdrawal, foreign_key: :address_hash)
timestamps() timestamps()
end end

@ -7,7 +7,7 @@ defmodule Explorer.Chain.Block do
use Explorer.Schema 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} alias Explorer.Chain.Block.{Reward, SecondDegreeRelation}
@optional_attrs ~w(size refetch_needed total_difficulty difficulty base_fee_per_gas)a @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(:rewards, Reward, foreign_key: :block_hash)
has_many(:withdrawals, Withdrawal, foreign_key: :block_hash)
has_one(:pending_operations, PendingBlockOperation, foreign_key: :block_hash) has_one(:pending_operations, PendingBlockOperation, foreign_key: :block_hash)
end end

@ -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

@ -18,7 +18,8 @@ defmodule Explorer.Chain.Import.Stage.BlockReferencing do
Runner.Tokens, Runner.Tokens,
Runner.TokenTransfers, Runner.TokenTransfers,
Runner.Address.TokenBalances, Runner.Address.TokenBalances,
Runner.TransactionActions Runner.TransactionActions,
Runner.Withdrawals
] ]
@impl Stage @impl Stage

@ -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

@ -2,6 +2,9 @@ defmodule Explorer.Helper do
@moduledoc """ @moduledoc """
Common explorer helper Common explorer helper
""" """
def parse_integer(nil), do: nil
def parse_integer(string) do def parse_integer(string) do
case Integer.parse(string) do case Integer.parse(string) do
{number, ""} -> number {number, ""} -> number

@ -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

@ -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

@ -39,7 +39,8 @@ defmodule Explorer.Factory do
Token, Token,
TokenTransfer, TokenTransfer,
Token.Instance, Token.Instance,
Transaction Transaction,
Withdrawal
} }
alias Explorer.SmartContract.Helper alias Explorer.SmartContract.Helper
@ -947,5 +948,28 @@ defmodule Explorer.Factory do
} }
end 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]) def random_bool, do: Enum.random([true, false])
end end

@ -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/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 - `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) - `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: 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` - `token_transfers`
- `transaction_actions` - `transaction_actions`
- `addresses` - `addresses`
- `withdrawals`
The following stubs for further async fetching are inserted as well: The following stubs for further async fetching are inserted as well:

@ -35,3 +35,8 @@ config :logger, :block_import_timings,
level: :debug, level: :debug,
path: Path.absname("logs/dev/indexer/block_import_timings.log"), path: Path.absname("logs/dev/indexer/block_import_timings.log"),
metadata_filter: [fetcher: :block_import_timings] metadata_filter: [fetcher: :block_import_timings]
config :logger, :withdrawal,
level: :debug,
path: Path.absname("logs/dev/indexer/withdrawal.log"),
metadata_filter: [fetcher: :withdrawal]

@ -42,3 +42,9 @@ config :logger, :block_import_timings,
path: Path.absname("logs/prod/indexer/block_import_timings.log"), path: Path.absname("logs/prod/indexer/block_import_timings.log"),
metadata_filter: [fetcher: :block_import_timings], metadata_filter: [fetcher: :block_import_timings],
rotate: %{max_bytes: 52_428_800, keep: 19} 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}

@ -130,6 +130,7 @@ defmodule Indexer.Block.Fetcher do
%Blocks{ %Blocks{
blocks_params: blocks_params, blocks_params: blocks_params,
transactions_params: transactions_params_without_receipts, transactions_params: transactions_params_without_receipts,
withdrawals_params: withdrawals_params,
block_second_degree_relations_params: block_second_degree_relations_params, block_second_degree_relations_params: block_second_degree_relations_params,
errors: blocks_errors errors: blocks_errors
}}} <- {:blocks, fetched_blocks}, }}} <- {:blocks, fetched_blocks},
@ -150,14 +151,16 @@ defmodule Indexer.Block.Fetcher do
mint_transfers: mint_transfers, mint_transfers: mint_transfers,
token_transfers: token_transfers, token_transfers: token_transfers,
transactions: transactions_with_receipts, transactions: transactions_with_receipts,
transaction_actions: transaction_actions transaction_actions: transaction_actions,
withdrawals: withdrawals_params
}), }),
coin_balances_params_set = coin_balances_params_set =
%{ %{
beneficiary_params: MapSet.to_list(beneficiary_params_set), beneficiary_params: MapSet.to_list(beneficiary_params_set),
blocks_params: blocks, blocks_params: blocks,
logs_params: logs, logs_params: logs,
transactions_params: transactions_with_receipts transactions_params: transactions_with_receipts,
withdrawals: withdrawals_params
} }
|> AddressCoinBalances.params_set(), |> AddressCoinBalances.params_set(),
coin_balances_params_daily_set = coin_balances_params_daily_set =
@ -186,7 +189,8 @@ defmodule Indexer.Block.Fetcher do
token_transfers: %{params: token_transfers}, token_transfers: %{params: token_transfers},
tokens: %{on_conflict: :nothing, params: tokens}, tokens: %{on_conflict: :nothing, params: tokens},
transactions: %{params: transactions_with_receipts}, transactions: %{params: transactions_with_receipts},
transaction_actions: %{params: transaction_actions} transaction_actions: %{params: transaction_actions},
withdrawals: %{params: withdrawals_params}
} }
) do ) do
Prometheus.Instrumenter.block_batch_fetch(fetch_time, callback_module) Prometheus.Instrumenter.block_batch_fetch(fetch_time, callback_module)

@ -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

@ -30,7 +30,8 @@ defmodule Indexer.Supervisor do
TokenBalance, TokenBalance,
TokenUpdater, TokenUpdater,
TransactionAction, TransactionAction,
UncleBlock UncleBlock,
Withdrawal
} }
alias Indexer.Temporary.{ alias Indexer.Temporary.{
@ -143,7 +144,8 @@ defmodule Indexer.Supervisor do
[ [
%{block_fetcher: block_fetcher, block_interval: block_interval, memory_monitor: memory_monitor}, %{block_fetcher: block_fetcher, block_interval: block_interval, memory_monitor: memory_monitor},
[name: BlockCatchup.Supervisor] [name: BlockCatchup.Supervisor]
]} ]},
{Withdrawal.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]}
] ]
|> List.flatten() |> List.flatten()

@ -50,6 +50,13 @@ defmodule Indexer.Transform.AddressCoinBalances do
when is_list(block_second_degree_relations_params), when is_list(block_second_degree_relations_params),
do: initial 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) defp internal_transactions_params_reducer(%{block_number: block_number} = internal_transaction_params, acc)
when is_integer(block_number) do when is_integer(block_number) do
case internal_transaction_params do case internal_transaction_params do

@ -133,6 +133,12 @@ defmodule Indexer.Transform.Addresses do
%{from: :block_number, to: :fetched_coin_balance_block_number}, %{from: :block_number, to: :fetched_coin_balance_block_number},
%{from: :address_hash, to: :hash} %{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(:address_hash) => String.t(),
required(:block_number) => non_neg_integer() required(:block_number) => non_neg_integer()
} }
],
optional(:withdrawals) => [
%{
required(:address_hash) => String.t(),
required(:block_number) => non_neg_integer()
}
] ]
}) :: [params] }) :: [params]
def extract_addresses(fetched_data, options \\ []) when is_map(fetched_data) and is_list(options) do def extract_addresses(fetched_data, options \\ []) when is_map(fetched_data) and is_list(options) do

@ -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

@ -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

@ -34,6 +34,7 @@ config :logger,
{LoggerFileBackend, :reading_token_functions}, {LoggerFileBackend, :reading_token_functions},
{LoggerFileBackend, :pending_transactions_to_refetch}, {LoggerFileBackend, :pending_transactions_to_refetch},
{LoggerFileBackend, :empty_blocks_to_refetch}, {LoggerFileBackend, :empty_blocks_to_refetch},
{LoggerFileBackend, :withdrawal},
{LoggerFileBackend, :api}, {LoggerFileBackend, :api},
{LoggerFileBackend, :block_import_timings}, {LoggerFileBackend, :block_import_timings},
{LoggerFileBackend, :account}, {LoggerFileBackend, :account},

@ -495,6 +495,11 @@ config :indexer, Indexer.Fetcher.CoinBalance,
batch_size: ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_BATCH_SIZE", 500), batch_size: ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_BATCH_SIZE", 500),
concurrency: ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_CONCURRENCY", 4) 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") Code.require_file("#{config_env()}.exs", "config/runtime")
for config <- "../apps/*/config/runtime/#{config_env()}.exs" |> Path.expand(__DIR__) |> Path.wildcard() do for config <- "../apps/*/config/runtime/#{config_env()}.exs" |> Path.expand(__DIR__) |> Path.wildcard() do

@ -117,6 +117,8 @@ INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER=false
# INDEXER_TX_ACTIONS_REINDEX_PROTOCOLS= # INDEXER_TX_ACTIONS_REINDEX_PROTOCOLS=
# INDEXER_TX_ACTIONS_AAVE_V3_POOL_CONTRACT= # INDEXER_TX_ACTIONS_AAVE_V3_POOL_CONTRACT=
# INDEXER_REALTIME_FETCHER_MAX_GAP= # INDEXER_REALTIME_FETCHER_MAX_GAP=
# INDEXER_DISABLE_WITHDRAWALS_FETCHER=
# WITHDRAWALS_FIRST_BLOCK=
# TOKEN_ID_MIGRATION_FIRST_BLOCK= # TOKEN_ID_MIGRATION_FIRST_BLOCK=
# TOKEN_ID_MIGRATION_CONCURRENCY= # TOKEN_ID_MIGRATION_CONCURRENCY=
# TOKEN_ID_MIGRATION_BATCH_SIZE= # TOKEN_ID_MIGRATION_BATCH_SIZE=

@ -554,6 +554,11 @@ ifdef INDEXER_REALTIME_FETCHER_MAX_GAP
endif endif
ifdef INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE ifdef INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE
BLOCKSCOUT_CONTAINER_PARAMS += -e 'INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE=$(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 endif
ifdef TOKEN_ID_MIGRATION_FIRST_BLOCK ifdef TOKEN_ID_MIGRATION_FIRST_BLOCK
BLOCKSCOUT_CONTAINER_PARAMS += -e 'TOKEN_ID_MIGRATION_FIRST_BLOCK=$(TOKEN_ID_MIGRATION_FIRST_BLOCK)' BLOCKSCOUT_CONTAINER_PARAMS += -e 'TOKEN_ID_MIGRATION_FIRST_BLOCK=$(TOKEN_ID_MIGRATION_FIRST_BLOCK)'

Loading…
Cancel
Save