Add withdrawals to explorer app Add withdrawals to indexer Add withdrawal tabs to address and block pages Add withdrawals test to web Add withdrawals test to eth json rpc Add withdrawals test to explorer Add withdrawal test to indexer Update CHANGELOG.md Update gettext [no ci] Update apps/indexer/config/prod.exs [no ci] Co-authored-by: nikitosing <32202610+nikitosing@users.noreply.github.com> Fix review Update CHANGELOG.md Update CHANGELOG.md Add withdrawal list page; Add withdrawal to APIv2 Add timestamp Fix nikitosing reviewpull/6694/head
parent
b98b7822e0
commit
43604709ad
@ -0,0 +1,121 @@ |
|||||||
|
defmodule BlockScoutWeb.AddressWithdrawalController do |
||||||
|
@moduledoc """ |
||||||
|
Display all the withdrawals that terminate at this Address. |
||||||
|
""" |
||||||
|
|
||||||
|
use BlockScoutWeb, :controller |
||||||
|
|
||||||
|
import BlockScoutWeb.Account.AuthController, only: [current_user: 1] |
||||||
|
|
||||||
|
import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] |
||||||
|
|
||||||
|
import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] |
||||||
|
|
||||||
|
alias BlockScoutWeb.{AccessHelper, AddressWithdrawalView, Controller} |
||||||
|
alias Explorer.{Chain, Market} |
||||||
|
|
||||||
|
alias Explorer.Chain.Wei |
||||||
|
|
||||||
|
alias Explorer.ExchangeRates.Token |
||||||
|
alias Indexer.Fetcher.CoinBalanceOnDemand |
||||||
|
alias Phoenix.View |
||||||
|
|
||||||
|
def index(conn, %{"address_id" => address_hash_string, "type" => "JSON"} = params) do |
||||||
|
address_options = [necessity_by_association: %{:names => :optional, :smart_contract => :optional}] |
||||||
|
|
||||||
|
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), |
||||||
|
{:ok, address} <- Chain.hash_to_address(address_hash, address_options, false), |
||||||
|
{:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do |
||||||
|
options = paging_options(params) |
||||||
|
|
||||||
|
withdrawals_plus_one = Chain.address_hash_to_withdrawals(address_hash, options) |
||||||
|
{withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) |
||||||
|
|
||||||
|
next_page_url = |
||||||
|
case next_page_params(next_page, withdrawals, params) do |
||||||
|
nil -> |
||||||
|
nil |
||||||
|
|
||||||
|
next_page_params -> |
||||||
|
address_withdrawal_path( |
||||||
|
conn, |
||||||
|
:index, |
||||||
|
address, |
||||||
|
Map.delete(next_page_params, "type") |
||||||
|
) |
||||||
|
end |
||||||
|
|
||||||
|
items_json = |
||||||
|
for withdrawal <- withdrawals do |
||||||
|
View.render_to_string(AddressWithdrawalView, "_withdrawal.html", withdrawal: withdrawal) |
||||||
|
end |
||||||
|
|
||||||
|
json(conn, %{items: items_json, next_page_path: next_page_url}) |
||||||
|
else |
||||||
|
:error -> |
||||||
|
unprocessable_entity(conn) |
||||||
|
|
||||||
|
{:restricted_access, _} -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, :not_found} -> |
||||||
|
case Chain.Hash.Address.validate(address_hash_string) do |
||||||
|
{:ok, _} -> |
||||||
|
json(conn, %{items: [], next_page_path: ""}) |
||||||
|
|
||||||
|
_ -> |
||||||
|
not_found(conn) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def index(conn, %{"address_id" => address_hash_string} = params) do |
||||||
|
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), |
||||||
|
{:ok, address} <- Chain.hash_to_address(address_hash), |
||||||
|
{:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do |
||||||
|
render( |
||||||
|
conn, |
||||||
|
"index.html", |
||||||
|
address: address, |
||||||
|
coin_balance_status: CoinBalanceOnDemand.trigger_fetch(address), |
||||||
|
exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null(), |
||||||
|
counters_path: address_path(conn, :address_counters, %{"id" => address_hash_string}), |
||||||
|
current_path: Controller.current_full_path(conn), |
||||||
|
tags: get_address_tags(address_hash, current_user(conn)) |
||||||
|
) |
||||||
|
else |
||||||
|
:error -> |
||||||
|
unprocessable_entity(conn) |
||||||
|
|
||||||
|
{:restricted_access, _} -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, :not_found} -> |
||||||
|
case Chain.Hash.Address.validate(address_hash_string) do |
||||||
|
{:ok, _} -> |
||||||
|
{:ok, address_hash} = Chain.string_to_address_hash(address_hash_string) |
||||||
|
|
||||||
|
address = %Chain.Address{ |
||||||
|
hash: address_hash, |
||||||
|
smart_contract: nil, |
||||||
|
token: nil, |
||||||
|
fetched_coin_balance: %Wei{value: Decimal.new(0)} |
||||||
|
} |
||||||
|
|
||||||
|
render( |
||||||
|
conn, |
||||||
|
"index.html", |
||||||
|
address: address, |
||||||
|
coin_balance_status: nil, |
||||||
|
exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null(), |
||||||
|
counters_path: address_path(conn, :address_counters, %{"id" => address_hash_string}), |
||||||
|
current_path: Controller.current_full_path(conn), |
||||||
|
tags: get_address_tags(address_hash, current_user(conn)) |
||||||
|
) |
||||||
|
|
||||||
|
_ -> |
||||||
|
not_found(conn) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,25 @@ |
|||||||
|
defmodule BlockScoutWeb.API.V2.WithdrawalController do |
||||||
|
use BlockScoutWeb, :controller |
||||||
|
|
||||||
|
import BlockScoutWeb.Chain, |
||||||
|
only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] |
||||||
|
|
||||||
|
import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] |
||||||
|
|
||||||
|
alias Explorer.Chain |
||||||
|
|
||||||
|
def withdrawals_list(conn, params) do |
||||||
|
full_options = |
||||||
|
[necessity_by_association: %{address: :optional, block: :optional}, api?: true] |
||||||
|
|> Keyword.merge(paging_options(params)) |
||||||
|
|
||||||
|
withdrawals_plus_one = Chain.list_withdrawals(full_options) |
||||||
|
{withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) |
||||||
|
|
||||||
|
next_page_params = next_page |> next_page_params(withdrawals, params) |> delete_parameters_from_next_page_params() |
||||||
|
|
||||||
|
conn |
||||||
|
|> put_status(200) |
||||||
|
|> render(:withdrawals, %{withdrawals: withdrawals, next_page_params: next_page_params}) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,106 @@ |
|||||||
|
defmodule BlockScoutWeb.BlockWithdrawalController do |
||||||
|
use BlockScoutWeb, :controller |
||||||
|
|
||||||
|
import BlockScoutWeb.Chain, |
||||||
|
only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] |
||||||
|
|
||||||
|
import BlockScoutWeb.BlockTransactionController, only: [param_block_hash_or_number_to_block: 2, block_above_tip: 1] |
||||||
|
|
||||||
|
alias BlockScoutWeb.{BlockTransactionView, BlockWithdrawalView, Controller} |
||||||
|
alias Explorer.Chain |
||||||
|
alias Phoenix.View |
||||||
|
|
||||||
|
def index(conn, %{"block_hash_or_number" => formatted_block_hash_or_number, "type" => "JSON"} = params) do |
||||||
|
case param_block_hash_or_number_to_block(formatted_block_hash_or_number, []) do |
||||||
|
{:ok, block} -> |
||||||
|
full_options = |
||||||
|
[necessity_by_association: %{address: :optional}] |
||||||
|
|> Keyword.merge(paging_options(params)) |
||||||
|
|
||||||
|
withdrawals_plus_one = Chain.block_to_withdrawals(block.hash, full_options) |
||||||
|
|
||||||
|
{withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) |
||||||
|
|
||||||
|
next_page_path = |
||||||
|
case next_page_params(next_page, withdrawals, params) do |
||||||
|
nil -> |
||||||
|
nil |
||||||
|
|
||||||
|
next_page_params -> |
||||||
|
block_withdrawal_path( |
||||||
|
conn, |
||||||
|
:index, |
||||||
|
block, |
||||||
|
Map.delete(next_page_params, "type") |
||||||
|
) |
||||||
|
end |
||||||
|
|
||||||
|
items = |
||||||
|
for withdrawal <- withdrawals do |
||||||
|
View.render_to_string(BlockWithdrawalView, "_withdrawal.html", withdrawal: withdrawal) |
||||||
|
end |
||||||
|
|
||||||
|
json( |
||||||
|
conn, |
||||||
|
%{ |
||||||
|
items: items, |
||||||
|
next_page_path: next_page_path |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
{:error, {:invalid, :hash}} -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, {:invalid, :number}} -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, :not_found} -> |
||||||
|
conn |
||||||
|
|> put_status(:not_found) |
||||||
|
|> render( |
||||||
|
BlockTransactionView, |
||||||
|
"404.html", |
||||||
|
block: nil, |
||||||
|
block_above_tip: block_above_tip(formatted_block_hash_or_number) |
||||||
|
) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def index(conn, %{"block_hash_or_number" => formatted_block_hash_or_number}) do |
||||||
|
case param_block_hash_or_number_to_block(formatted_block_hash_or_number, |
||||||
|
necessity_by_association: %{ |
||||||
|
[miner: :names] => :required, |
||||||
|
:uncles => :optional, |
||||||
|
:nephews => :optional, |
||||||
|
:rewards => :optional |
||||||
|
} |
||||||
|
) do |
||||||
|
{:ok, block} -> |
||||||
|
block_transaction_count = Chain.block_to_transaction_count(block.hash) |
||||||
|
|
||||||
|
render( |
||||||
|
conn, |
||||||
|
"index.html", |
||||||
|
block: block, |
||||||
|
block_transaction_count: block_transaction_count, |
||||||
|
current_path: Controller.current_full_path(conn) |
||||||
|
) |
||||||
|
|
||||||
|
{:error, {:invalid, :hash}} -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, {:invalid, :number}} -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, :not_found} -> |
||||||
|
conn |
||||||
|
|> put_status(:not_found) |
||||||
|
|> render( |
||||||
|
BlockTransactionView, |
||||||
|
"404.html", |
||||||
|
block: nil, |
||||||
|
block_above_tip: block_above_tip(formatted_block_hash_or_number) |
||||||
|
) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,39 @@ |
|||||||
|
defmodule BlockScoutWeb.WithdrawalController do |
||||||
|
use BlockScoutWeb, :controller |
||||||
|
|
||||||
|
import BlockScoutWeb.Chain, |
||||||
|
only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1, fetch_page_number: 1] |
||||||
|
|
||||||
|
alias BlockScoutWeb.{Controller, WithdrawalView} |
||||||
|
alias Explorer.Chain |
||||||
|
alias Phoenix.View |
||||||
|
|
||||||
|
def index(conn, %{"type" => "JSON"} = params) do |
||||||
|
full_options = |
||||||
|
[necessity_by_association: %{address: :optional, block: :optional}] |
||||||
|
|> Keyword.merge(paging_options(params)) |
||||||
|
|
||||||
|
withdrawals_plus_one = Chain.list_withdrawals(full_options) |
||||||
|
{withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) |
||||||
|
|
||||||
|
items = |
||||||
|
for withdrawal <- withdrawals do |
||||||
|
View.render_to_string(WithdrawalView, "_withdrawal.html", withdrawal: withdrawal) |
||||||
|
end |
||||||
|
|
||||||
|
next_page_path = |
||||||
|
case next_page_params(next_page, withdrawals, params) do |
||||||
|
nil -> nil |
||||||
|
next_page_params -> withdrawal_path(conn, :index, Map.delete(next_page_params, "type")) |
||||||
|
end |
||||||
|
|
||||||
|
json(conn, %{items: items, next_page_path: next_page_path}) |
||||||
|
end |
||||||
|
|
||||||
|
def index(conn, params) do |
||||||
|
render(conn, "index.html", |
||||||
|
current_path: Controller.current_full_path(conn), |
||||||
|
page_number: params |> fetch_page_number() |> Integer.to_string() |
||||||
|
) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1 @@ |
|||||||
|
<%= render BlockScoutWeb.AddressView, "_metatags.html", conn: @conn, address: @address %> |
@ -0,0 +1,25 @@ |
|||||||
|
<tr data-identifier-hash="<%= @withdrawal.index %>"> |
||||||
|
<!-- Index --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= @withdrawal.index %></span> |
||||||
|
</td> |
||||||
|
<!-- Validator Index --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= @withdrawal.validator_index %></span> |
||||||
|
</td> |
||||||
|
<!-- Block --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<%= render BlockScoutWeb.BlockView, |
||||||
|
"_number_link.html", |
||||||
|
block: @withdrawal.block |
||||||
|
%> |
||||||
|
</td> |
||||||
|
<!-- Age --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span data-from-now="<%= @withdrawal.block.timestamp %>"></span> |
||||||
|
</td> |
||||||
|
<!-- Amount --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= format_wei_value(@withdrawal.amount, :ether) %></span> |
||||||
|
</td> |
||||||
|
</tr> |
@ -0,0 +1,66 @@ |
|||||||
|
<section class="container"> |
||||||
|
|
||||||
|
<% is_proxy = BlockScoutWeb.AddressView.smart_contract_is_proxy?(@address) %> |
||||||
|
|
||||||
|
<%= render BlockScoutWeb.AddressView, "overview.html", address: @address, is_proxy: is_proxy, conn: @conn, exchange_rate: @exchange_rate, coin_balance_status: @coin_balance_status, counters_path: @counters_path, tags: @tags %> |
||||||
|
|
||||||
|
<section data-page="address-withdrawals" id="withdrawals"> |
||||||
|
<div class="card"> |
||||||
|
<%= render BlockScoutWeb.AddressView, "_tabs.html", address: @address, is_proxy: is_proxy, conn: @conn %> |
||||||
|
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>"> |
||||||
|
<div class="clearfix"> |
||||||
|
|
||||||
|
<h2 class="card-title float-left"><%= gettext "Withdrawals" %></h2> |
||||||
|
<div class="top-pagination-outer-container float-right"> |
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;"> |
||||||
|
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span> |
||||||
|
</button> |
||||||
|
|
||||||
|
<div class="addresses-table-container"> |
||||||
|
<div class="stakes-table-container"> |
||||||
|
<table> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Index" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Validator index" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Block" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Age" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Amount" %></div> |
||||||
|
</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody data-items data-selector="address-withdrawals"> |
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 5 %> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div data-empty-response-message style="display: none;"> |
||||||
|
<div class="tile tile-muted text-center" data-selector="empty-withdrawals-list"> |
||||||
|
<%= gettext "There are no withdrawals for this address." %> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="top-pagination-outer-container float-right"> |
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/address.js") %>"></script> |
||||||
|
</section> |
||||||
|
</section> |
@ -0,0 +1,4 @@ |
|||||||
|
<%= link( |
||||||
|
to_string(@block.number), |
||||||
|
to: block_path(BlockScoutWeb.Endpoint, :show, @block) |
||||||
|
) %> |
@ -0,0 +1,19 @@ |
|||||||
|
<div class="card-tabs js-card-tabs"> |
||||||
|
<%= |
||||||
|
link( |
||||||
|
gettext("Transactions"), |
||||||
|
class: "card-tab #{tab_status("transactions", @conn.request_path)}", |
||||||
|
to: block_transaction_path(@conn, :index, @conn.params["block_hash_or_number"]) |
||||||
|
) |
||||||
|
%> |
||||||
|
|
||||||
|
<%= if Chain.check_if_withdrawals_in_block(@block.hash) do %> |
||||||
|
<%= |
||||||
|
link( |
||||||
|
gettext("Withdrawals"), |
||||||
|
class: "card-tab #{tab_status("withdrawals", @conn.request_path)}", |
||||||
|
to: block_withdrawal_path(@conn, :index, @conn.params["block_hash_or_number"]) |
||||||
|
) |
||||||
|
%> |
||||||
|
<% end %> |
||||||
|
</div> |
@ -0,0 +1 @@ |
|||||||
|
<%= render BlockScoutWeb.BlockView, "_metatags.html", conn: @conn, block: @block %> |
@ -0,0 +1,23 @@ |
|||||||
|
<tr data-identifier-hash="<%= @withdrawal.index %>"> |
||||||
|
<!-- Index --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= @withdrawal.index %></span> |
||||||
|
</td> |
||||||
|
<!-- Validator Index --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= @withdrawal.validator_index %></span> |
||||||
|
</td> |
||||||
|
<!-- To --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<%= render BlockScoutWeb.AddressView, |
||||||
|
"_link.html", |
||||||
|
address: @withdrawal.address, |
||||||
|
contract: BlockScoutWeb.AddressView.contract?(@withdrawal.address), |
||||||
|
use_custom_tooltip: false |
||||||
|
%> |
||||||
|
</td> |
||||||
|
<!-- Amount --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= format_wei_value(@withdrawal.amount, :ether) %></span> |
||||||
|
</td> |
||||||
|
</tr> |
@ -0,0 +1,54 @@ |
|||||||
|
<section class="container"> |
||||||
|
|
||||||
|
<%= render BlockScoutWeb.BlockView, "overview.html", assigns %> |
||||||
|
|
||||||
|
<section> |
||||||
|
<div class="card mb-3"> |
||||||
|
<%= render BlockScoutWeb.BlockView, "_tabs.html", assigns %> |
||||||
|
|
||||||
|
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>" id="withdrawals"> |
||||||
|
|
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||||
|
|
||||||
|
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;"> |
||||||
|
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span> |
||||||
|
</button> |
||||||
|
|
||||||
|
<div class="addresses-table-container"> |
||||||
|
<div class="stakes-table-container"> |
||||||
|
<table> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Index" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Validator index" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "To" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Amount" %></div> |
||||||
|
</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody data-items data-selector="block-withdrawals"> |
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 4 %> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div data-empty-response-message style="display: none;"> |
||||||
|
<div class="tile tile-muted text-center" data-selector="empty-withdrawals-list"> |
||||||
|
<%= gettext "There are no withdrawals for this block." %> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", cur_page_number: "1", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||||
|
|
||||||
|
</div> |
||||||
|
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/async-listing-load.js") %>"></script> |
||||||
|
</section> |
||||||
|
</section> |
@ -0,0 +1,8 @@ |
|||||||
|
<title> |
||||||
|
<%= gettext( |
||||||
|
"Beacon chain withdrawals - %{subnetwork} Explorer", |
||||||
|
subnetwork: BlockScoutWeb.LayoutView.subnetwork_title() |
||||||
|
) %> |
||||||
|
</title> |
||||||
|
<meta name="keywords" content="<%= gettext "Beacon chain, Withdrawals, %{subnetwork}, %{coin}", subnetwork: BlockScoutWeb.LayoutView.subnetwork_title(), coin: Explorer.coin() %>"> |
||||||
|
<meta name="description" content="<%= gettext "View the beacon chain withdrawals on %{subnetwork}", subnetwork: BlockScoutWeb.LayoutView.subnetwork_title() %>"> |
@ -0,0 +1,34 @@ |
|||||||
|
<tr data-identifier-hash="<%= @withdrawal.index %>"> |
||||||
|
<!-- Index --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= @withdrawal.index %></span> |
||||||
|
</td> |
||||||
|
<!-- Validator Index --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= @withdrawal.validator_index %></span> |
||||||
|
</td> |
||||||
|
<!-- Block --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<%= render BlockScoutWeb.BlockView, |
||||||
|
"_number_link.html", |
||||||
|
block: @withdrawal.block |
||||||
|
%> |
||||||
|
</td> |
||||||
|
<!-- To --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<%= render BlockScoutWeb.AddressView, |
||||||
|
"_link.html", |
||||||
|
address: @withdrawal.address, |
||||||
|
contract: BlockScoutWeb.AddressView.contract?(@withdrawal.address), |
||||||
|
use_custom_tooltip: false |
||||||
|
%> |
||||||
|
</td> |
||||||
|
<!-- Age --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span data-from-now="<%= @withdrawal.block.timestamp %>"></span> |
||||||
|
</td> |
||||||
|
<!-- Amount --> |
||||||
|
<td class="stakes-td"> |
||||||
|
<span><%= format_wei_value(@withdrawal.amount, :ether) %></span> |
||||||
|
</td> |
||||||
|
</tr> |
@ -0,0 +1,59 @@ |
|||||||
|
<section class="container" data-page="verified-contracts-list"> |
||||||
|
<%= render BlockScoutWeb.Advertisement.TextAdView, "index.html", conn: @conn %> |
||||||
|
<div class="card"> |
||||||
|
<div id="withdrawals-list" class="card-body" data-async-load data-async-listing="<%= @current_path %>"> |
||||||
|
<h1 class="card-title list-title-description d-inline-block"><%= gettext "Withdrawals" %></h1> |
||||||
|
|
||||||
|
<div class="float-right"> |
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", cur_page_number: @page_number, show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;"> |
||||||
|
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span> |
||||||
|
</button> |
||||||
|
|
||||||
|
<div class="addresses-table-container"> |
||||||
|
<div class="stakes-table-container"> |
||||||
|
<table> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Index" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Validator index" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Block" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "To" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Age" %></div> |
||||||
|
</th> |
||||||
|
<th class="stakes-table-th"> |
||||||
|
<div class="stakes-table-th-content"><%= gettext "Amount" %></div> |
||||||
|
</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody data-items data-selector="address-withdrawals"> |
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_table-loader.html", columns_num: 6 %> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div data-empty-response-message style="display: none;"> |
||||||
|
<br> |
||||||
|
<div class="tile tile-muted text-center"> |
||||||
|
<span data-selector="empty-verified-contracts-list"> |
||||||
|
<%= gettext "There are no withdrawals." %> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", cur_page_number: @page_number, show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||||
|
</div> |
||||||
|
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/async-listing-load.js") %>"></script> |
||||||
|
</section> |
@ -0,0 +1,3 @@ |
|||||||
|
defmodule BlockScoutWeb.AddressWithdrawalView do |
||||||
|
use BlockScoutWeb, :view |
||||||
|
end |
@ -0,0 +1,41 @@ |
|||||||
|
defmodule BlockScoutWeb.API.V2.WithdrawalView do |
||||||
|
use BlockScoutWeb, :view |
||||||
|
|
||||||
|
alias BlockScoutWeb.API.V2.Helper |
||||||
|
alias Explorer.Chain.Withdrawal |
||||||
|
|
||||||
|
def render("withdrawals.json", %{withdrawals: withdrawals, next_page_params: next_page_params}) do |
||||||
|
%{"items" => Enum.map(withdrawals, &prepare_withdrawal(&1)), "next_page_params" => next_page_params} |
||||||
|
end |
||||||
|
|
||||||
|
@spec prepare_withdrawal(Withdrawal.t()) :: map() |
||||||
|
def prepare_withdrawal(%Withdrawal{block: %Ecto.Association.NotLoaded{}} = withdrawal) do |
||||||
|
%{ |
||||||
|
"index" => withdrawal.index, |
||||||
|
"validator_index" => withdrawal.validator_index, |
||||||
|
"receiver" => Helper.address_with_info(withdrawal.address, withdrawal.address_hash), |
||||||
|
"amount" => withdrawal.amount |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
def prepare_withdrawal(%Withdrawal{address: %Ecto.Association.NotLoaded{}} = withdrawal) do |
||||||
|
%{ |
||||||
|
"index" => withdrawal.index, |
||||||
|
"validator_index" => withdrawal.validator_index, |
||||||
|
"block_number" => withdrawal.block.number, |
||||||
|
"amount" => withdrawal.amount, |
||||||
|
"timestamp" => withdrawal.block.timestamp |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
def prepare_withdrawal(%Withdrawal{} = withdrawal) do |
||||||
|
%{ |
||||||
|
"index" => withdrawal.index, |
||||||
|
"validator_index" => withdrawal.validator_index, |
||||||
|
"block_number" => withdrawal.block.number, |
||||||
|
"receiver" => Helper.address_with_info(withdrawal.address, withdrawal.address_hash), |
||||||
|
"amount" => withdrawal.amount, |
||||||
|
"timestamp" => withdrawal.block.timestamp |
||||||
|
} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,3 @@ |
|||||||
|
defmodule BlockScoutWeb.BlockWithdrawalView do |
||||||
|
use BlockScoutWeb, :view |
||||||
|
end |
@ -0,0 +1,3 @@ |
|||||||
|
defmodule BlockScoutWeb.WithdrawalView do |
||||||
|
use BlockScoutWeb, :view |
||||||
|
end |
@ -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 |
@ -0,0 +1,55 @@ |
|||||||
|
defmodule BlockScoutWeb.API.V2.WithdrawalControllerTest do |
||||||
|
use BlockScoutWeb.ConnCase |
||||||
|
|
||||||
|
alias Explorer.Chain.Withdrawal |
||||||
|
|
||||||
|
describe "/withdrawals" do |
||||||
|
test "empty lists", %{conn: conn} do |
||||||
|
request = get(conn, "/api/v2/blocks") |
||||||
|
assert response = json_response(request, 200) |
||||||
|
assert response["items"] == [] |
||||||
|
assert response["next_page_params"] == nil |
||||||
|
end |
||||||
|
|
||||||
|
test "get withdrawal", %{conn: conn} do |
||||||
|
block = insert(:withdrawal) |
||||||
|
|
||||||
|
request = get(conn, "/api/v2/withdrawals") |
||||||
|
|
||||||
|
assert response = json_response(request, 200) |
||||||
|
assert Enum.count(response["items"]) == 1 |
||||||
|
assert response["next_page_params"] == nil |
||||||
|
compare_item(block, Enum.at(response["items"], 0)) |
||||||
|
end |
||||||
|
|
||||||
|
test "can paginate", %{conn: conn} do |
||||||
|
withdrawals = |
||||||
|
51 |
||||||
|
|> insert_list(:withdrawal) |
||||||
|
|
||||||
|
request = get(conn, "/api/v2/withdrawals") |
||||||
|
assert response = json_response(request, 200) |
||||||
|
|
||||||
|
request_2nd_page = get(conn, "/api/v2/withdrawals", response["next_page_params"]) |
||||||
|
|
||||||
|
assert response_2nd_page = json_response(request_2nd_page, 200) |
||||||
|
|
||||||
|
check_paginated_response(response, response_2nd_page, withdrawals) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp compare_item(%Withdrawal{} = withdrawal, json) do |
||||||
|
assert withdrawal.index == json["index"] |
||||||
|
end |
||||||
|
|
||||||
|
defp check_paginated_response(first_page_resp, second_page_resp, list) do |
||||||
|
assert Enum.count(first_page_resp["items"]) == 50 |
||||||
|
assert first_page_resp["next_page_params"] != nil |
||||||
|
compare_item(Enum.at(list, 50), Enum.at(first_page_resp["items"], 0)) |
||||||
|
compare_item(Enum.at(list, 1), Enum.at(first_page_resp["items"], 49)) |
||||||
|
|
||||||
|
assert Enum.count(second_page_resp["items"]) == 1 |
||||||
|
assert second_page_resp["next_page_params"] == nil |
||||||
|
compare_item(Enum.at(list, 0), Enum.at(second_page_resp["items"], 0)) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,139 @@ |
|||||||
|
defmodule BlockScoutWeb.BlockWithdrawalControllerTest do |
||||||
|
use BlockScoutWeb.ConnCase |
||||||
|
|
||||||
|
import BlockScoutWeb.WebRouter.Helpers, only: [block_withdrawal_path: 3] |
||||||
|
|
||||||
|
describe "GET index/2" do |
||||||
|
test "with invalid block number", %{conn: conn} do |
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, "unknown")) |
||||||
|
|
||||||
|
assert html_response(conn, 404) |
||||||
|
end |
||||||
|
|
||||||
|
test "with valid block number below the tip", %{conn: conn} do |
||||||
|
insert(:block, number: 666) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, "1")) |
||||||
|
|
||||||
|
assert html_response(conn, 404) =~ "This block has not been processed yet." |
||||||
|
end |
||||||
|
|
||||||
|
test "with valid block number above the tip", %{conn: conn} do |
||||||
|
block = insert(:block) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, block.number + 1)) |
||||||
|
|
||||||
|
assert_block_above_tip(conn) |
||||||
|
end |
||||||
|
|
||||||
|
test "returns withdrawals for the block", %{conn: conn} do |
||||||
|
block = insert(:block, withdrawals: insert_list(3, :withdrawal)) |
||||||
|
|
||||||
|
# to check that we can render a block overview |
||||||
|
get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block)) |
||||||
|
conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) |
||||||
|
|
||||||
|
assert json_response(conn, 200) |
||||||
|
|
||||||
|
{:ok, %{"items" => items}} = |
||||||
|
conn.resp_body |
||||||
|
|> Poison.decode() |
||||||
|
|
||||||
|
assert Enum.count(items) == 3 |
||||||
|
end |
||||||
|
|
||||||
|
test "non-consensus block number without consensus blocks is treated as consensus number above tip", %{conn: conn} do |
||||||
|
block = insert(:block, consensus: false) |
||||||
|
|
||||||
|
transaction = insert(:transaction) |
||||||
|
insert(:transaction_fork, hash: transaction.hash, uncle_hash: block.hash) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, block.number)) |
||||||
|
|
||||||
|
assert_block_above_tip(conn) |
||||||
|
end |
||||||
|
|
||||||
|
test "non-consensus block number above consensus block number is treated as consensus number above tip", %{ |
||||||
|
conn: conn |
||||||
|
} do |
||||||
|
consensus_block = insert(:block, consensus: true, number: 1) |
||||||
|
block = insert(:block, consensus: false, number: consensus_block.number + 1) |
||||||
|
|
||||||
|
transaction = insert(:transaction) |
||||||
|
insert(:transaction_fork, hash: transaction.hash, uncle_hash: block.hash) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, block.number)) |
||||||
|
|
||||||
|
assert_block_above_tip(conn) |
||||||
|
end |
||||||
|
|
||||||
|
test "does not return transactions for invalid block hash", %{conn: conn} do |
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, "0x0")) |
||||||
|
|
||||||
|
assert html_response(conn, 404) |
||||||
|
end |
||||||
|
|
||||||
|
test "with valid not-indexed hash", %{conn: conn} do |
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, block_hash())) |
||||||
|
|
||||||
|
assert html_response(conn, 404) =~ "Block not found, please try again later." |
||||||
|
end |
||||||
|
|
||||||
|
test "does not return unrelated transactions", %{conn: conn} do |
||||||
|
insert(:withdrawal) |
||||||
|
block = insert(:block) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) |
||||||
|
|
||||||
|
assert json_response(conn, 200) |
||||||
|
|
||||||
|
{:ok, %{"items" => items}} = |
||||||
|
conn.resp_body |
||||||
|
|> Poison.decode() |
||||||
|
|
||||||
|
assert Enum.empty?(items) |
||||||
|
end |
||||||
|
|
||||||
|
test "next_page_path exists if not on last page", %{conn: conn} do |
||||||
|
block = insert(:block, withdrawals: insert_list(60, :withdrawal)) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) |
||||||
|
|
||||||
|
{:ok, %{"next_page_path" => next_page_path}} = |
||||||
|
conn.resp_body |
||||||
|
|> Poison.decode() |
||||||
|
|
||||||
|
assert next_page_path |
||||||
|
end |
||||||
|
|
||||||
|
test "next_page_path is empty if on last page", %{conn: conn} do |
||||||
|
block = insert(:block, withdrawals: insert_list(1, :withdrawal)) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(BlockScoutWeb.Endpoint, :index, block), %{type: "JSON"}) |
||||||
|
|
||||||
|
{:ok, %{"next_page_path" => next_page_path}} = |
||||||
|
conn.resp_body |
||||||
|
|> Poison.decode() |
||||||
|
|
||||||
|
refute next_page_path |
||||||
|
end |
||||||
|
|
||||||
|
test "displays miner primary address name", %{conn: conn} do |
||||||
|
miner_name = "POA Miner Pool" |
||||||
|
%{address: miner_address} = insert(:address_name, name: miner_name, primary: true) |
||||||
|
|
||||||
|
block = insert(:block, miner: miner_address, miner_hash: nil) |
||||||
|
|
||||||
|
conn = get(conn, block_withdrawal_path(conn, :index, block)) |
||||||
|
assert html_response(conn, 200) =~ miner_name |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp assert_block_above_tip(conn) do |
||||||
|
assert conn |
||||||
|
|> html_response(404) |
||||||
|
|> Floki.find(~S|.error-descr|) |
||||||
|
|> Floki.text() |
||||||
|
|> String.trim() == "Easy Cowboy! This block does not exist yet!" |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,60 @@ |
|||||||
|
defmodule BlockScoutWeb.WithdrawalControllerTest do |
||||||
|
use BlockScoutWeb.ConnCase |
||||||
|
|
||||||
|
import BlockScoutWeb.WebRouter.Helpers, only: [withdrawal_path: 2, withdrawal_path: 3] |
||||||
|
|
||||||
|
alias Explorer.Chain.Withdrawal |
||||||
|
|
||||||
|
describe "GET index/2" do |
||||||
|
test "returns all withdrawals", %{conn: conn} do |
||||||
|
insert_list(4, :withdrawal) |
||||||
|
|
||||||
|
conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) |
||||||
|
|
||||||
|
items = Map.get(json_response(conn, 200), "items") |
||||||
|
|
||||||
|
assert length(items) == 4 |
||||||
|
end |
||||||
|
|
||||||
|
test "returns next page of results based on last withdrawal", %{conn: conn} do |
||||||
|
insert_list(50, :withdrawal) |
||||||
|
|
||||||
|
withdrawal = insert(:withdrawal) |
||||||
|
|
||||||
|
conn = |
||||||
|
get(conn, withdrawal_path(conn, :index), %{ |
||||||
|
"type" => "JSON", |
||||||
|
"index" => Integer.to_string(withdrawal.index) |
||||||
|
}) |
||||||
|
|
||||||
|
items = Map.get(json_response(conn, 200), "items") |
||||||
|
|
||||||
|
assert length(items) == 50 |
||||||
|
end |
||||||
|
|
||||||
|
test "next_page_path exist if not on last page", %{conn: conn} do |
||||||
|
%Withdrawal{index: index} = |
||||||
|
60 |
||||||
|
|> insert_list(:withdrawal) |
||||||
|
|> Enum.fetch!(10) |
||||||
|
|
||||||
|
conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) |
||||||
|
|
||||||
|
expected_path = |
||||||
|
withdrawal_path(conn, :index, %{ |
||||||
|
index: index, |
||||||
|
items_count: "50" |
||||||
|
}) |
||||||
|
|
||||||
|
assert Map.get(json_response(conn, 200), "next_page_path") == expected_path |
||||||
|
end |
||||||
|
|
||||||
|
test "next_page_path is empty if on last page", %{conn: conn} do |
||||||
|
insert(:withdrawal) |
||||||
|
|
||||||
|
conn = get(conn, withdrawal_path(conn, :index), %{"type" => "JSON"}) |
||||||
|
|
||||||
|
refute conn |> json_response(200) |> Map.get("next_page_path") |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,101 @@ |
|||||||
|
defmodule EthereumJSONRPC.Withdrawal do |
||||||
|
@moduledoc """ |
||||||
|
Withdrawal format included in the return of |
||||||
|
`eth_getBlockByHash` and `eth_getBlockByNumber` |
||||||
|
""" |
||||||
|
|
||||||
|
import EthereumJSONRPC, only: [quantity_to_integer: 1] |
||||||
|
|
||||||
|
@type elixir :: %{ |
||||||
|
String.t() => EthereumJSONRPC.address() | EthereumJSONRPC.hash() | String.t() | non_neg_integer() | nil |
||||||
|
} |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
* `"index"` - the withdrawal number `t:EthereumJSONRPC.quantity/0`. |
||||||
|
* `"validatorIndex"` - the validator number initiated the withdrawal `t:EthereumJSONRPC.quantity/0`. |
||||||
|
* `"address"` - `t:EthereumJSONRPC.address/0` of the receiver. |
||||||
|
* `"amount"` - `t:EthereumJSONRPC.quantity/0` of wei transferred. |
||||||
|
""" |
||||||
|
@type t :: %{ |
||||||
|
String.t() => |
||||||
|
EthereumJSONRPC.address() | EthereumJSONRPC.hash() | EthereumJSONRPC.quantity() | String.t() | nil |
||||||
|
} |
||||||
|
|
||||||
|
@type params :: %{ |
||||||
|
index: non_neg_integer(), |
||||||
|
validator_index: non_neg_integer(), |
||||||
|
address_hash: EthereumJSONRPC.address(), |
||||||
|
block_hash: EthereumJSONRPC.hash(), |
||||||
|
block_number: non_neg_integer(), |
||||||
|
amount: non_neg_integer() |
||||||
|
} |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Converts `t:elixir/0` to `t:params/0`. |
||||||
|
|
||||||
|
iex> EthereumJSONRPC.Withdrawal.elixir_to_params( |
||||||
|
...> %{ |
||||||
|
...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
...> "amount" => 4040000000000, |
||||||
|
...> "index" => 3867, |
||||||
|
...> "validatorIndex" => 1721, |
||||||
|
...> "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", |
||||||
|
...> "blockNumber" => 3 |
||||||
|
...> } |
||||||
|
...> ) |
||||||
|
%{ |
||||||
|
address_hash: "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
amount: 4040000000000, |
||||||
|
block_hash: "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", |
||||||
|
block_number: 3, |
||||||
|
index: 3867, |
||||||
|
validator_index: 1721 |
||||||
|
} |
||||||
|
""" |
||||||
|
@spec elixir_to_params(elixir) :: params |
||||||
|
def elixir_to_params(%{ |
||||||
|
"index" => index, |
||||||
|
"validatorIndex" => validator_index, |
||||||
|
"address" => address_hash, |
||||||
|
"amount" => amount, |
||||||
|
"blockHash" => block_hash, |
||||||
|
"blockNumber" => block_number |
||||||
|
}) do |
||||||
|
%{ |
||||||
|
index: index, |
||||||
|
validator_index: validator_index, |
||||||
|
address_hash: address_hash, |
||||||
|
block_hash: block_hash, |
||||||
|
block_number: block_number, |
||||||
|
amount: amount |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Decodes the stringly typed numerical fields to `t:non_neg_integer/0`. |
||||||
|
|
||||||
|
iex> EthereumJSONRPC.Withdrawal.to_elixir( |
||||||
|
...> %{ |
||||||
|
...> "index" => "0xf1b", |
||||||
|
...> "validatorIndex" => "0x6b9", |
||||||
|
...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
...> "amount" => "0x3aca2c3d000" |
||||||
|
...> }, "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", 1 |
||||||
|
...> ) |
||||||
|
%{ |
||||||
|
"address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
"amount" => 4040000000000, |
||||||
|
"blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", |
||||||
|
"index" => 3867, |
||||||
|
"validatorIndex" => 1721, |
||||||
|
"blockNumber" => 1 |
||||||
|
} |
||||||
|
""" |
||||||
|
@spec to_elixir(%{String.t() => String.t()}, String.t(), non_neg_integer()) :: elixir |
||||||
|
def to_elixir(withdrawal, block_hash, block_number) when is_map(withdrawal) do |
||||||
|
Enum.into(withdrawal, %{"blockHash" => block_hash, "blockNumber" => block_number}, &entry_to_elixir/1) |
||||||
|
end |
||||||
|
|
||||||
|
defp entry_to_elixir({key, value}) when key in ~w(index validatorIndex amount), do: {key, quantity_to_integer(value)} |
||||||
|
defp entry_to_elixir({key, value}) when key in ~w(address), do: {key, value} |
||||||
|
end |
@ -0,0 +1,67 @@ |
|||||||
|
defmodule EthereumJSONRPC.Withdrawals do |
||||||
|
@moduledoc """ |
||||||
|
List of withdrawals format included in the return of |
||||||
|
`eth_getBlockByHash` and `eth_getBlockByNumber` |
||||||
|
""" |
||||||
|
|
||||||
|
alias EthereumJSONRPC.Withdrawal |
||||||
|
|
||||||
|
@type elixir :: [Withdrawal.elixir()] |
||||||
|
@type params :: [Withdrawal.params()] |
||||||
|
@type t :: [Withdrawal.t()] |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Converts `t:elixir/0` to `t:params/0`. |
||||||
|
|
||||||
|
iex> EthereumJSONRPC.Withdrawals.elixir_to_params([ |
||||||
|
...> %{ |
||||||
|
...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
...> "amount" => 4040000000000, |
||||||
|
...> "index" => 3867, |
||||||
|
...> "validatorIndex" => 1721, |
||||||
|
...> "blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", |
||||||
|
...> "blockNumber" => 1 |
||||||
|
...> } |
||||||
|
...> ]) |
||||||
|
[ |
||||||
|
%{ |
||||||
|
address_hash: "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
amount: 4040000000000, |
||||||
|
block_hash: "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", |
||||||
|
index: 3867, |
||||||
|
validator_index: 1721, |
||||||
|
block_number: 1 |
||||||
|
} |
||||||
|
] |
||||||
|
""" |
||||||
|
@spec elixir_to_params(elixir) :: params |
||||||
|
def elixir_to_params(elixir) when is_list(elixir) do |
||||||
|
Enum.map(elixir, &Withdrawal.elixir_to_params/1) |
||||||
|
end |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Decodes stringly typed fields in entries of `withdrawals`. |
||||||
|
|
||||||
|
iex> EthereumJSONRPC.Withdrawals.to_elixir([ |
||||||
|
...> %{ |
||||||
|
...> "index" => "0xf1b", |
||||||
|
...> "validatorIndex" => "0x6b9", |
||||||
|
...> "address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
...> "amount" => "0x3aca2c3d000" |
||||||
|
...> }], "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", 3) |
||||||
|
[ |
||||||
|
%{ |
||||||
|
"address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
"amount" => 4040000000000, |
||||||
|
"blockHash" => "0x7f035c5f3c0678250853a1fde6027def7cac1812667bd0d5ab7ccb94eb8b6f3a", |
||||||
|
"index" => 3867, |
||||||
|
"blockNumber" => 3, |
||||||
|
"validatorIndex" => 1721 |
||||||
|
} |
||||||
|
] |
||||||
|
""" |
||||||
|
@spec to_elixir([%{String.t() => String.t()}], String.t(), non_neg_integer()) :: elixir |
||||||
|
def to_elixir(withdrawals, block_hash, block_number) when is_list(withdrawals) do |
||||||
|
Enum.map(withdrawals, &Withdrawal.to_elixir(&1, block_hash, block_number)) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
defmodule EthereumJSONRPC.WithdrawalTest do |
||||||
|
use ExUnit.Case, async: true |
||||||
|
|
||||||
|
doctest EthereumJSONRPC.Withdrawal |
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
defmodule EthereumJSONRPC.WithdrawalsTest do |
||||||
|
use ExUnit.Case, async: true |
||||||
|
|
||||||
|
doctest EthereumJSONRPC.Withdrawals |
||||||
|
end |
@ -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 |
@ -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 |
@ -0,0 +1,19 @@ |
|||||||
|
defmodule Explorer.Repo.Migrations.CreareWithdrawals do |
||||||
|
use Ecto.Migration |
||||||
|
|
||||||
|
def change do |
||||||
|
create table(:withdrawals, primary_key: false) do |
||||||
|
add(:index, :integer, null: false, primary_key: true) |
||||||
|
add(:validator_index, :integer, null: false) |
||||||
|
add(:amount, :numeric, precision: 100, null: false) |
||||||
|
|
||||||
|
timestamps(null: false, type: :utc_datetime_usec) |
||||||
|
|
||||||
|
add(:address_hash, references(:addresses, column: :hash, on_delete: :delete_all, type: :bytea), null: false) |
||||||
|
add(:block_hash, references(:blocks, column: :hash, on_delete: :delete_all, type: :bytea), null: false) |
||||||
|
end |
||||||
|
|
||||||
|
create(index(:withdrawals, [:address_hash])) |
||||||
|
create(index(:withdrawals, [:block_hash])) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,82 @@ |
|||||||
|
defmodule Explorer.Chain.WithdrawalTest do |
||||||
|
use Explorer.DataCase |
||||||
|
|
||||||
|
alias Ecto.Changeset |
||||||
|
alias Explorer.Chain.Withdrawal |
||||||
|
alias Explorer.Chain |
||||||
|
|
||||||
|
describe "changeset/2" do |
||||||
|
test "with valid attributes" do |
||||||
|
assert %Changeset{valid?: true} = |
||||||
|
:withdrawal |
||||||
|
|> build() |
||||||
|
|> Withdrawal.changeset(%{}) |
||||||
|
end |
||||||
|
|
||||||
|
test "with invalid attributes" do |
||||||
|
changeset = %Withdrawal{} |> Withdrawal.changeset(%{racecar: "yellow ham"}) |
||||||
|
refute(changeset.valid?) |
||||||
|
end |
||||||
|
|
||||||
|
test "with duplicate information" do |
||||||
|
%Withdrawal{index: index} = insert(:withdrawal) |
||||||
|
|
||||||
|
assert {:error, %Changeset{valid?: false, errors: [index: {"has already been taken", _}]}} = |
||||||
|
%Withdrawal{} |
||||||
|
|> Withdrawal.changeset(params_for(:withdrawal, index: index)) |
||||||
|
|> Repo.insert() |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "block_hash_to_withdrawals_query/1" do |
||||||
|
test "finds only withdrawals of this block" do |
||||||
|
withdrawal_a = insert(:withdrawal) |
||||||
|
withdrawal_b = insert(:withdrawal) |
||||||
|
|
||||||
|
results = |
||||||
|
Withdrawal.block_hash_to_withdrawals_query(withdrawal_a.block_hash) |
||||||
|
|> Repo.all() |
||||||
|
|> Enum.map(& &1.index) |
||||||
|
|
||||||
|
refute Enum.member?(results, withdrawal_b.index) |
||||||
|
assert Enum.member?(results, withdrawal_a.index) |
||||||
|
end |
||||||
|
|
||||||
|
test "order the results DESC by index" do |
||||||
|
block = insert(:block, withdrawals: insert_list(50, :withdrawal)) |
||||||
|
|
||||||
|
results = |
||||||
|
Withdrawal.block_hash_to_withdrawals_query(block.hash) |
||||||
|
|> Repo.all() |
||||||
|
|> Enum.map(& &1.index) |
||||||
|
|
||||||
|
assert results |> Enum.sort(:desc) == results |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "address_hash_to_withdrawals_query/1" do |
||||||
|
test "finds only withdrawals of this address" do |
||||||
|
withdrawal_a = insert(:withdrawal) |
||||||
|
withdrawal_b = insert(:withdrawal) |
||||||
|
|
||||||
|
results = |
||||||
|
Withdrawal.address_hash_to_withdrawals_query(withdrawal_a.address_hash) |
||||||
|
|> Repo.all() |
||||||
|
|> Enum.map(& &1.index) |
||||||
|
|
||||||
|
refute Enum.member?(results, withdrawal_b.index) |
||||||
|
assert Enum.member?(results, withdrawal_a.index) |
||||||
|
end |
||||||
|
|
||||||
|
test "order the results DESC by index" do |
||||||
|
address = insert(:address, withdrawals: insert_list(50, :withdrawal)) |
||||||
|
|
||||||
|
results = |
||||||
|
Withdrawal.address_hash_to_withdrawals_query(address.hash) |
||||||
|
|> Repo.all() |
||||||
|
|> Enum.map(& &1.index) |
||||||
|
|
||||||
|
assert results |> Enum.sort(:desc) == results |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -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 |
@ -0,0 +1,152 @@ |
|||||||
|
defmodule Indexer.Fetcher.WithdrawalTest do |
||||||
|
use EthereumJSONRPC.Case |
||||||
|
use Explorer.DataCase |
||||||
|
|
||||||
|
import Mox |
||||||
|
import EthereumJSONRPC, only: [integer_to_quantity: 1] |
||||||
|
|
||||||
|
alias Explorer.Chain |
||||||
|
alias Indexer.Fetcher.Withdrawal |
||||||
|
|
||||||
|
setup :verify_on_exit! |
||||||
|
setup :set_mox_global |
||||||
|
|
||||||
|
setup do |
||||||
|
initial_env = Application.get_all_env(:indexer) |
||||||
|
on_exit(fn -> Application.put_all_env([{:indexer, initial_env}]) end) |
||||||
|
end |
||||||
|
|
||||||
|
test "do not crash app when WITHDRAWALS_FIRST_BLOCK is undefined", %{ |
||||||
|
json_rpc_named_arguments: json_rpc_named_arguments |
||||||
|
} do |
||||||
|
Application.put_env(:indexer, Withdrawal.Supervisor, disabled?: "false") |
||||||
|
Withdrawal.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||||
|
|
||||||
|
assert [{Indexer.Fetcher.Withdrawal, :undefined, :worker, [Indexer.Fetcher.Withdrawal]} | _] = |
||||||
|
Withdrawal.Supervisor |> Supervisor.which_children() |
||||||
|
end |
||||||
|
|
||||||
|
test "do not start when all old blocks are fetched", %{json_rpc_named_arguments: json_rpc_named_arguments} do |
||||||
|
Application.put_env(:indexer, Withdrawal.Supervisor, disabled?: "false") |
||||||
|
Withdrawal.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||||
|
|
||||||
|
Application.put_env(:indexer, Withdrawal, first_block: "0") |
||||||
|
|
||||||
|
assert [{Indexer.Fetcher.Withdrawal, :undefined, :worker, [Indexer.Fetcher.Withdrawal]} | _] = |
||||||
|
Withdrawal.Supervisor |> Supervisor.which_children() |
||||||
|
end |
||||||
|
|
||||||
|
test "stops when all old blocks are fetched", %{json_rpc_named_arguments: json_rpc_named_arguments} do |
||||||
|
Application.put_env(:indexer, Withdrawal.Supervisor, disabled?: "false") |
||||||
|
Application.put_env(:indexer, Withdrawal, first_block: "0") |
||||||
|
|
||||||
|
block_a = insert(:block) |
||||||
|
block_b = insert(:block) |
||||||
|
|
||||||
|
block_a_number_string = integer_to_quantity(block_a.number) |
||||||
|
block_b_number_string = integer_to_quantity(block_b.number) |
||||||
|
|
||||||
|
EthereumJSONRPC.Mox |
||||||
|
|> expect(:json_rpc, 2, fn requests, _options -> |
||||||
|
{:ok, |
||||||
|
Enum.map(requests, fn |
||||||
|
%{id: id, method: "eth_getBlockByNumber", params: [^block_a_number_string, true]} -> |
||||||
|
%{ |
||||||
|
id: id, |
||||||
|
result: %{ |
||||||
|
"author" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", |
||||||
|
"difficulty" => "0x6bc767dd80781", |
||||||
|
"extraData" => "0x5050594520737061726b706f6f6c2d6574682d7477", |
||||||
|
"gasLimit" => "0x7a121d", |
||||||
|
"gasUsed" => "0x79cbe9", |
||||||
|
"hash" => to_string(block_a.hash), |
||||||
|
"logsBloom" => |
||||||
|
"0x044d42d008801488400e1809190200a80d06105bc0c4100b047895c0d518327048496108388040140010b8208006288102e206160e21052322440924002090c1c808a0817405ab238086d028211014058e949401012403210314896702d06880c815c3060a0f0809987c81044488292cc11d57882c912a808ca10471c84460460040000c0001012804022000a42106591881d34407420ba401e1c08a8d00a000a34c11821a80222818a4102152c8a0c044032080c6462644223104d618e0e544072008120104408205c60510542264808488220403000106281a0290404220112c10b080145028c8000300b18a2c8280701c882e702210b00410834840108084", |
||||||
|
"miner" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", |
||||||
|
"mixHash" => "0xda53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", |
||||||
|
"nonce" => "0x0946e5f01fce12bc", |
||||||
|
"number" => "0x708677", |
||||||
|
"parentHash" => "0x62543e836e0ef7edfa9e38f26526092c4be97efdf5ba9e0f53a4b0b7d5bc930a", |
||||||
|
"receiptsRoot" => "0xa7d2b82bd8526de11736c18bd5cc8cfe2692106c4364526f3310ad56d78669c4", |
||||||
|
"sealFields" => [ |
||||||
|
"0xa0da53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", |
||||||
|
"0x880946e5f01fce12bc" |
||||||
|
], |
||||||
|
"sha3Uncles" => "0x483a8a21a5825ad270f358b3ea56e060bbb8b3082d9a92ec8fa17a5c7e6fc1b6", |
||||||
|
"size" => "0x544c", |
||||||
|
"stateRoot" => "0x85daa9cd528004c1609d4cb3520fd958e85983bb4183124a4a9f7137fd39c691", |
||||||
|
"timestamp" => "0x5c8bc76e", |
||||||
|
"totalDifficulty" => "0x201a42c35142ae94458", |
||||||
|
"transactions" => [], |
||||||
|
"transactionsRoot" => "0xcd6c12fa43cd4e92ad5c0bf232b30488bbcbfe273c5b4af0366fced0767d54db", |
||||||
|
"uncles" => [], |
||||||
|
"withdrawals" => [ |
||||||
|
%{ |
||||||
|
"index" => "0x1", |
||||||
|
"validatorIndex" => "0x80b", |
||||||
|
"address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
"amount" => "0x2c17a12dc00" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
%{id: id, method: "eth_getBlockByNumber", params: [^block_b_number_string, true]} -> |
||||||
|
%{ |
||||||
|
id: id, |
||||||
|
result: %{ |
||||||
|
"author" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", |
||||||
|
"difficulty" => "0x6bc767dd80781", |
||||||
|
"extraData" => "0x5050594520737061726b706f6f6c2d6574682d7477", |
||||||
|
"gasLimit" => "0x7a121d", |
||||||
|
"gasUsed" => "0x79cbe9", |
||||||
|
"hash" => to_string(block_b.hash), |
||||||
|
"logsBloom" => |
||||||
|
"0x044d42d008801488400e1809190200a80d06105bc0c4100b047895c0d518327048496108388040140010b8208006288102e206160e21052322440924002090c1c808a0817405ab238086d028211014058e949401012403210314896702d06880c815c3060a0f0809987c81044488292cc11d57882c912a808ca10471c84460460040000c0001012804022000a42106591881d34407420ba401e1c08a8d00a000a34c11821a80222818a4102152c8a0c044032080c6462644223104d618e0e544072008120104408205c60510542264808488220403000106281a0290404220112c10b080145028c8000300b18a2c8280701c882e702210b00410834840108084", |
||||||
|
"miner" => "0x5a0b54d5dc17e0aadc383d2db43b0a0d3e029c4c", |
||||||
|
"mixHash" => "0xda53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", |
||||||
|
"nonce" => "0x0946e5f01fce12bc", |
||||||
|
"number" => "0x708677", |
||||||
|
"parentHash" => "0x62543e836e0ef7edfa9e38f26526092c4be97efdf5ba9e0f53a4b0b7d5bc930a", |
||||||
|
"receiptsRoot" => "0xa7d2b82bd8526de11736c18bd5cc8cfe2692106c4364526f3310ad56d78669c4", |
||||||
|
"sealFields" => [ |
||||||
|
"0xa0da53ae7c2b3c529783d6cdacdb90587fd70eb651c0f04253e8ff17de97844010", |
||||||
|
"0x880946e5f01fce12bc" |
||||||
|
], |
||||||
|
"sha3Uncles" => "0x483a8a21a5825ad270f358b3ea56e060bbb8b3082d9a92ec8fa17a5c7e6fc1b6", |
||||||
|
"size" => "0x544c", |
||||||
|
"stateRoot" => "0x85daa9cd528004c1609d4cb3520fd958e85983bb4183124a4a9f7137fd39c691", |
||||||
|
"timestamp" => "0x5c8bc76e", |
||||||
|
"totalDifficulty" => "0x201a42c35142ae94458", |
||||||
|
"transactions" => [], |
||||||
|
"transactionsRoot" => "0xcd6c12fa43cd4e92ad5c0bf232b30488bbcbfe273c5b4af0366fced0767d54db", |
||||||
|
"uncles" => [], |
||||||
|
"withdrawals" => [ |
||||||
|
%{ |
||||||
|
"index" => "0x2", |
||||||
|
"validatorIndex" => "0x80b", |
||||||
|
"address" => "0x388ea662ef2c223ec0b047d41bf3c0f362142ad5", |
||||||
|
"amount" => "0x2c17a12dc00" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
|
end)} |
||||||
|
end) |
||||||
|
|
||||||
|
pid = Withdrawal.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||||
|
|
||||||
|
assert [{Indexer.Fetcher.Withdrawal, worker_pid, :worker, [Indexer.Fetcher.Withdrawal]} | _] = |
||||||
|
Withdrawal.Supervisor |> Supervisor.which_children() |
||||||
|
|
||||||
|
assert is_pid(worker_pid) |
||||||
|
|
||||||
|
:timer.sleep(300) |
||||||
|
|
||||||
|
assert [{Indexer.Fetcher.Withdrawal, :undefined, :worker, [Indexer.Fetcher.Withdrawal]} | _] = |
||||||
|
Withdrawal.Supervisor |> Supervisor.which_children() |
||||||
|
|
||||||
|
# Terminates the process so it finishes all Ecto processes. |
||||||
|
GenServer.stop(pid) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,17 @@ |
|||||||
|
defmodule Indexer.Fetcher.Withdrawal.Supervisor.Case do |
||||||
|
alias Indexer.Fetcher.Withdrawal |
||||||
|
|
||||||
|
def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do |
||||||
|
merged_fetcher_arguments = |
||||||
|
Keyword.merge( |
||||||
|
fetcher_arguments, |
||||||
|
interval: 1, |
||||||
|
max_batch_size: 1, |
||||||
|
max_concurrency: 1 |
||||||
|
) |
||||||
|
|
||||||
|
[merged_fetcher_arguments] |
||||||
|
|> Withdrawal.Supervisor.child_spec() |
||||||
|
|> ExUnit.Callbacks.start_supervised!() |
||||||
|
end |
||||||
|
end |
Loading…
Reference in new issue