parent
b856eed594
commit
e9e03120c9
@ -0,0 +1,36 @@ |
|||||||
|
defmodule BlockScoutWeb.Tokens.HolderController do |
||||||
|
use BlockScoutWeb, :controller |
||||||
|
|
||||||
|
alias Explorer.Chain |
||||||
|
|
||||||
|
import BlockScoutWeb.Chain, |
||||||
|
only: [ |
||||||
|
split_list_by_page: 1, |
||||||
|
paging_options: 1, |
||||||
|
next_page_params: 3 |
||||||
|
] |
||||||
|
|
||||||
|
def index(conn, %{"token_id" => address_hash_string} = params) do |
||||||
|
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), |
||||||
|
{:ok, token} <- Chain.token_from_address_hash(address_hash), |
||||||
|
token_balances <- Chain.fetch_token_holders_from_token_hash(address_hash, paging_options(params)) do |
||||||
|
{token_balances_paginated, next_page} = split_list_by_page(token_balances) |
||||||
|
|
||||||
|
render( |
||||||
|
conn, |
||||||
|
"index.html", |
||||||
|
token: token, |
||||||
|
token_balances: token_balances_paginated, |
||||||
|
total_address_in_token_transfers: Chain.count_addresses_in_token_transfers_from_token_hash(address_hash), |
||||||
|
total_token_transfers: Chain.count_token_transfers_from_token_hash(address_hash), |
||||||
|
next_page_params: next_page_params(next_page, token_balances_paginated, params) |
||||||
|
) |
||||||
|
else |
||||||
|
:error -> |
||||||
|
not_found(conn) |
||||||
|
|
||||||
|
{:error, :not_found} -> |
||||||
|
not_found(conn) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,19 @@ |
|||||||
|
<div class="tile tile-type-token fade-in mb-10" data-test="token_holders"> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-md-7 col-lg-8 d-flex flex-column"> |
||||||
|
<span> |
||||||
|
<%= render BlockScoutWeb.AddressView, "_link.html", address_hash: @token_balance.address_hash, contract: BlockScoutWeb.AddressView.contract?(@token_balance.address) %> |
||||||
|
</span> |
||||||
|
|
||||||
|
<span> |
||||||
|
<span class="text-dark"> |
||||||
|
<%= format_token_balance_value(@token_balance.value, @token) %> <%= @token.symbol %> |
||||||
|
</span> |
||||||
|
|
||||||
|
<%= if @token.total_supply > 0 do %> |
||||||
|
(<%= total_supply_percentage(@token_balance.value, @token.total_supply) %>) |
||||||
|
<% end %> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
@ -0,0 +1,94 @@ |
|||||||
|
<section class="container"> |
||||||
|
<%= render( |
||||||
|
OverviewView, |
||||||
|
"_details.html", |
||||||
|
token: @token, |
||||||
|
total_token_transfers: @total_token_transfers, |
||||||
|
total_address_in_token_transfers: @total_address_in_token_transfers |
||||||
|
) %> |
||||||
|
|
||||||
|
<section> |
||||||
|
<div class="card"> |
||||||
|
<div class="card-header"> |
||||||
|
<!-- DESKTOP TAB NAV --> |
||||||
|
<ul class="nav nav-tabs card-header-tabs d-none d-md-inline-flex"> |
||||||
|
<li class="nav-item"> |
||||||
|
<%= link( |
||||||
|
gettext("Token Transfers"), |
||||||
|
class: "nav-link", |
||||||
|
to: token_path(@conn, :show, @token.contract_address_hash) |
||||||
|
) %> |
||||||
|
</li> |
||||||
|
|
||||||
|
<%= if TokenView.smart_contract_with_read_only_functions?(@token) do %> |
||||||
|
<li class="nav-item"> |
||||||
|
<%= link( |
||||||
|
gettext("Read Contract"), |
||||||
|
to: token_read_contract_path(@conn, :index, @conn.params["id"]), |
||||||
|
class: "nav-link")%> |
||||||
|
</li> |
||||||
|
<% end %> |
||||||
|
|
||||||
|
<li class="nav-item"> |
||||||
|
<%= link( |
||||||
|
gettext("Token Holders"), |
||||||
|
class: "nav-link active", |
||||||
|
"data-test": "token_holders_tab", |
||||||
|
to: token_holder_path(@conn, :index, @token.contract_address_hash) |
||||||
|
) %> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
|
||||||
|
<!-- MOBILE DROPDOWN NAV --> |
||||||
|
<ul class="nav nav-tabs card-header-tabs d-md-none"> |
||||||
|
<li class="nav-item dropdown flex-fill text-center"> |
||||||
|
<a class="nav-link active dropdown-toggle" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false"><%= gettext("Token Holders") %></a> |
||||||
|
<div class="dropdown-menu"> |
||||||
|
<%= link( |
||||||
|
gettext("Token Transfers"), |
||||||
|
class: "dropdown-item", |
||||||
|
to: token_path(@conn, :show, @token.contract_address_hash) |
||||||
|
) %> |
||||||
|
<%= if TokenView.smart_contract_with_read_only_functions?(@token) do %> |
||||||
|
<%= link( |
||||||
|
gettext("Read Contract"), |
||||||
|
to: "#", |
||||||
|
class: "dropdown-item")%> |
||||||
|
<% end %> |
||||||
|
<%= link( |
||||||
|
gettext("Token Holders"), |
||||||
|
class: "dropdown-item", |
||||||
|
to: token_holder_path(@conn, :index, @token.contract_address_hash) |
||||||
|
) %> |
||||||
|
</div> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Token Holders --> |
||||||
|
<div class="card-body"> |
||||||
|
<h2 class="card-title"><%= gettext "Token Holders" %></h2> |
||||||
|
|
||||||
|
<%= if Enum.any?(@token_balances) do %> |
||||||
|
<%= for token_balance <- @token_balances do %> |
||||||
|
<%= render "_token_balances.html", token: @token, token_balance: token_balance %> |
||||||
|
<% end %> |
||||||
|
<% else %> |
||||||
|
<div class="tile tile-muted text-center"> |
||||||
|
<span data-selector="empty-transactions-list"> |
||||||
|
<%= gettext "There are no holders for this Token." %> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<% end %> |
||||||
|
|
||||||
|
<%= if @next_page_params do %> |
||||||
|
<%= link( |
||||||
|
gettext("Next Page"), |
||||||
|
class: "button button-secondary button-small float-right mt-4", |
||||||
|
to: token_holder_path(@conn, :index, @token.contract_address_hash, @next_page_params) |
||||||
|
) %> |
||||||
|
<% end %> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
</section> |
@ -0,0 +1,50 @@ |
|||||||
|
defmodule BlockScoutWeb.Tokens.HolderView do |
||||||
|
use BlockScoutWeb, :view |
||||||
|
|
||||||
|
alias BlockScoutWeb.Tokens.{OverviewView, TokenView} |
||||||
|
alias Explorer.Chain.{Token} |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Calculates the percentage of the value from the given total supply. |
||||||
|
|
||||||
|
## Examples |
||||||
|
|
||||||
|
iex> value = Decimal.new(200) |
||||||
|
iex> total_supply = Decimal.new(1000) |
||||||
|
iex> BlockScoutWeb.Tokens.HolderView.total_supply_percentage(value, total_supply) |
||||||
|
"20.0000%" |
||||||
|
|
||||||
|
""" |
||||||
|
def total_supply_percentage(value, total_supply) do |
||||||
|
result = |
||||||
|
value |
||||||
|
|> Decimal.div(total_supply) |
||||||
|
|> Decimal.mult(100) |
||||||
|
|> Decimal.round(4) |
||||||
|
|> Decimal.to_string() |
||||||
|
|
||||||
|
result <> "%" |
||||||
|
end |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Formats the token balance value according to the Token's type. |
||||||
|
|
||||||
|
## Examples |
||||||
|
|
||||||
|
iex> token = build(:token, type: "ERC-20", decimals: 2) |
||||||
|
iex> BlockScoutWeb.Tokens.HolderView.format_token_balance_value(100000, token) |
||||||
|
"1,000" |
||||||
|
|
||||||
|
iex> token = build(:token, type: "ERC-721") |
||||||
|
iex> BlockScoutWeb.Tokens.HolderView.format_token_balance_value(1, token) |
||||||
|
1 |
||||||
|
|
||||||
|
""" |
||||||
|
def format_token_balance_value(value, %Token{type: "ERC-20", decimals: decimals}) do |
||||||
|
format_according_to_decimals(value, decimals) |
||||||
|
end |
||||||
|
|
||||||
|
def format_token_balance_value(value, _token) do |
||||||
|
value |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,92 @@ |
|||||||
|
defmodule BlockScoutWeb.Tokens.HolderControllerTest do |
||||||
|
use BlockScoutWeb.ConnCase |
||||||
|
|
||||||
|
alias Explorer.Chain.Hash |
||||||
|
|
||||||
|
describe "GET index/3" do |
||||||
|
test "with invalid address hash", %{conn: conn} do |
||||||
|
conn = get(conn, token_holder_path(BlockScoutWeb.Endpoint, :index, "invalid_address")) |
||||||
|
|
||||||
|
assert html_response(conn, 404) |
||||||
|
end |
||||||
|
|
||||||
|
test "with a token that doesn't exist", %{conn: conn} do |
||||||
|
address = build(:address) |
||||||
|
conn = get(conn, token_holder_path(BlockScoutWeb.Endpoint, :index, address.hash)) |
||||||
|
|
||||||
|
assert html_response(conn, 404) |
||||||
|
end |
||||||
|
|
||||||
|
test "successfully renders the page", %{conn: conn} do |
||||||
|
token = insert(:token) |
||||||
|
|
||||||
|
insert_list( |
||||||
|
2, |
||||||
|
:token_balance, |
||||||
|
token_contract_address_hash: token.contract_address_hash |
||||||
|
) |
||||||
|
|
||||||
|
conn = |
||||||
|
get( |
||||||
|
conn, |
||||||
|
token_holder_path(BlockScoutWeb.Endpoint, :index, token.contract_address_hash) |
||||||
|
) |
||||||
|
|
||||||
|
assert html_response(conn, 200) |
||||||
|
end |
||||||
|
|
||||||
|
test "returns next page of results based on last seen token balance", %{conn: conn} do |
||||||
|
contract_address = build(:contract_address, hash: "0x6937cb25eb54bc013b9c13c47ab38eb63edd1493") |
||||||
|
token = insert(:token, contract_address: contract_address) |
||||||
|
|
||||||
|
second_page_token_balances = |
||||||
|
1..50 |
||||||
|
|> Enum.map( |
||||||
|
&insert( |
||||||
|
:token_balance, |
||||||
|
token_contract_address_hash: token.contract_address_hash, |
||||||
|
value: &1 + 1000 |
||||||
|
) |
||||||
|
) |
||||||
|
|> Enum.map(& &1.value) |
||||||
|
|
||||||
|
token_balance = |
||||||
|
insert( |
||||||
|
:token_balance, |
||||||
|
token_contract_address_hash: token.contract_address_hash, |
||||||
|
value: 50000 |
||||||
|
) |
||||||
|
|
||||||
|
conn = |
||||||
|
get(conn, token_holder_path(conn, :index, token.contract_address_hash), %{ |
||||||
|
"value" => Decimal.to_integer(token_balance.value), |
||||||
|
"address_hash" => Hash.to_string(token_balance.address_hash) |
||||||
|
}) |
||||||
|
|
||||||
|
actual_token_balances = |
||||||
|
conn.assigns.token_balances |
||||||
|
|> Enum.map(& &1.value) |
||||||
|
|> Enum.reverse() |
||||||
|
|
||||||
|
assert second_page_token_balances == actual_token_balances |
||||||
|
end |
||||||
|
|
||||||
|
test "next_page_params exists if not on last page", %{conn: conn} do |
||||||
|
contract_address = build(:contract_address, hash: "0x6937cb25eb54bc013b9c13c47ab38eb63edd1493") |
||||||
|
token = insert(:token, contract_address: contract_address) |
||||||
|
|
||||||
|
Enum.each( |
||||||
|
1..51, |
||||||
|
&insert( |
||||||
|
:token_balance, |
||||||
|
token_contract_address_hash: token.contract_address_hash, |
||||||
|
value: &1 + 1000 |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
conn = get(conn, token_holder_path(conn, :index, token.contract_address_hash)) |
||||||
|
|
||||||
|
assert conn.assigns.next_page_params |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,23 @@ |
|||||||
|
defmodule BlockScoutWeb.TokenPage do |
||||||
|
@moduledoc false |
||||||
|
|
||||||
|
use Wallaby.DSL |
||||||
|
import Wallaby.Query, only: [css: 1, css: 2] |
||||||
|
alias Explorer.Chain.{Address} |
||||||
|
|
||||||
|
def visit_page(session, %Address{hash: address_hash}) do |
||||||
|
visit_page(session, address_hash) |
||||||
|
end |
||||||
|
|
||||||
|
def visit_page(session, contract_address_hash) do |
||||||
|
visit(session, "tokens/#{contract_address_hash}") |
||||||
|
end |
||||||
|
|
||||||
|
def click_tokens_holders(session) do |
||||||
|
click(session, css("[data-test='token_holders_tab']")) |
||||||
|
end |
||||||
|
|
||||||
|
def token_holders(count: count) do |
||||||
|
css("[data-test='token_holders']", count: count) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,22 @@ |
|||||||
|
defmodule BlockScoutWeb.ViewingTokensTest do |
||||||
|
use BlockScoutWeb.FeatureCase, async: true |
||||||
|
|
||||||
|
alias BlockScoutWeb.TokenPage |
||||||
|
|
||||||
|
describe "viewing token holders" do |
||||||
|
test "list the token holders", %{session: session} do |
||||||
|
token = insert(:token) |
||||||
|
|
||||||
|
insert_list( |
||||||
|
2, |
||||||
|
:token_balance, |
||||||
|
token_contract_address_hash: token.contract_address_hash |
||||||
|
) |
||||||
|
|
||||||
|
session |
||||||
|
|> TokenPage.visit_page(token.contract_address) |
||||||
|
|> TokenPage.click_tokens_holders() |
||||||
|
|> assert_has(TokenPage.token_holders(count: 2)) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,40 @@ |
|||||||
|
defmodule BlockScoutWeb.Tokens.HolderViewTest do |
||||||
|
use BlockScoutWeb.ConnCase, async: true |
||||||
|
|
||||||
|
alias BlockScoutWeb.Tokens.HolderView |
||||||
|
alias Explorer.Chain.{Address.TokenBalance, Token} |
||||||
|
|
||||||
|
doctest BlockScoutWeb.Tokens.HolderView, import: true |
||||||
|
|
||||||
|
describe "total_supply_percentage/2" do |
||||||
|
test "returns the percentage of the Token total supply" do |
||||||
|
%Token{total_supply: total_supply} = build(:token, total_supply: 1000) |
||||||
|
%TokenBalance{value: value} = build(:token_balance, value: 200) |
||||||
|
|
||||||
|
assert HolderView.total_supply_percentage(value, total_supply) == "20.0000%" |
||||||
|
end |
||||||
|
|
||||||
|
test "considers 4 decimals" do |
||||||
|
%Token{total_supply: total_supply} = build(:token, total_supply: 100_000_009) |
||||||
|
%TokenBalance{value: value} = build(:token_balance, value: 500) |
||||||
|
|
||||||
|
assert HolderView.total_supply_percentage(value, total_supply) == "0.0005%" |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "format_token_balance_value/1" do |
||||||
|
test "formats according to token decimals when it's a ERC-20" do |
||||||
|
token = build(:token, type: "ERC-20", decimals: 2) |
||||||
|
token_balance = build(:token_balance, value: 2_000_000) |
||||||
|
|
||||||
|
assert HolderView.format_token_balance_value(token_balance.value, token) == "20,000" |
||||||
|
end |
||||||
|
|
||||||
|
test "returns the value when it's ERC-721" do |
||||||
|
token = build(:token, type: "ERC-721") |
||||||
|
token_balance = build(:token_balance, value: 1) |
||||||
|
|
||||||
|
assert HolderView.format_token_balance_value(token_balance.value, token) == 1 |
||||||
|
end |
||||||
|
end |
||||||
|
end |
Loading…
Reference in new issue