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