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