Merge pull request #534 from poanetwork/frg-list-tokens-on-address-page
List of tokens associated with the Address on the Address' pagepull/556/head
commit
67b415ddae
@ -0,0 +1,35 @@ |
|||||||
|
.token-balance-dropdown { |
||||||
|
min-width: 14.375rem; |
||||||
|
margin-top: 1rem; |
||||||
|
background-color: $gray-100; |
||||||
|
box-shadow: 0px 2px 3px 2px $gray-200; |
||||||
|
border: none; |
||||||
|
|
||||||
|
// Overriding style added by Bootstrap dropdown via JS. |
||||||
|
left: -17px !important; |
||||||
|
|
||||||
|
.dropdown-items { |
||||||
|
overflow-y: auto; |
||||||
|
max-height: 18.5rem; |
||||||
|
|
||||||
|
.dropdown-item:hover { |
||||||
|
color: $white; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
&:after, &:before { |
||||||
|
bottom: 100%; |
||||||
|
left: 14%; |
||||||
|
border: solid transparent; |
||||||
|
content: " "; |
||||||
|
height: 0; |
||||||
|
width: 0; |
||||||
|
position: absolute; |
||||||
|
} |
||||||
|
|
||||||
|
&:before { |
||||||
|
border-bottom-color: $gray-100; |
||||||
|
border-width: 1rem; |
||||||
|
margin-left: -1rem; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
.address-overview { |
||||||
|
.card-section { |
||||||
|
margin-bottom: 3rem; |
||||||
|
} |
||||||
|
|
||||||
|
.card { |
||||||
|
margin-bottom: 0; |
||||||
|
height: 100%; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import $ from 'jquery' |
||||||
|
|
||||||
|
const tokenBalanceDropdown = (element) => { |
||||||
|
const $element = $(element) |
||||||
|
const $loading = $element.find('[data-loading]') |
||||||
|
const $errorMessage = $element.find('[data-error-message]') |
||||||
|
const apiPath = element.dataset.api_path |
||||||
|
|
||||||
|
$loading.show() |
||||||
|
|
||||||
|
$.get(apiPath) |
||||||
|
.done(response => $element.html(response)) |
||||||
|
.fail(() => { |
||||||
|
$loading.hide() |
||||||
|
$errorMessage.show() |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
$('[data-token-balance-dropdown]').each((_index, element) => tokenBalanceDropdown(element)) |
@ -0,0 +1,24 @@ |
|||||||
|
defmodule BlockScoutWeb.AddressTokenBalanceController do |
||||||
|
use BlockScoutWeb, :controller |
||||||
|
|
||||||
|
alias Explorer.Chain |
||||||
|
alias Explorer.Token.BalanceReader |
||||||
|
|
||||||
|
def index(conn, %{"address_id" => address_hash_string}) do |
||||||
|
with true <- ajax?(conn), |
||||||
|
{:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string) do |
||||||
|
token_balances = |
||||||
|
address_hash |
||||||
|
|> Chain.fetch_tokens_from_address_hash() |
||||||
|
|> BalanceReader.fetch_token_balances_without_error(address_hash_string) |
||||||
|
|
||||||
|
conn |
||||||
|
|> put_status(200) |
||||||
|
|> put_layout(false) |
||||||
|
|> render("_token_balances.html", tokens: token_balances) |
||||||
|
else |
||||||
|
_ -> |
||||||
|
not_found(conn) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,20 @@ |
|||||||
|
<div class="card"> |
||||||
|
<div class="card-body"> |
||||||
|
<h2 class="card-title"><%= gettext "Token Holdings" %></h2> |
||||||
|
|
||||||
|
<!-- Dropdown --> |
||||||
|
<div data-token-balance-dropdown |
||||||
|
data-api_path=<%= address_token_balance_path(@conn, :index, :en, @address.hash) %> |
||||||
|
class="icon-links ml-3 mb-3" |
||||||
|
> |
||||||
|
<p data-loading class="mb-0" stytle="display: none"> |
||||||
|
<i class="fa fa-spinner fa-spin"></i> |
||||||
|
<%= gettext("Fetching tokens...") %> |
||||||
|
</p> |
||||||
|
|
||||||
|
<p data-error-message class="mb-0" style="display: none"> |
||||||
|
<%= gettext("Error tryng to fetch balances.") %> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
@ -0,0 +1,36 @@ |
|||||||
|
<%= if Enum.any?(@tokens) do %> |
||||||
|
<a href="#" |
||||||
|
data-dropdown-toggle |
||||||
|
data-toggle="dropdown" |
||||||
|
role="button" |
||||||
|
class="icon-link" |
||||||
|
id="dropdown-tokens" |
||||||
|
aria-haspopup="true" |
||||||
|
aria-expanded="false" |
||||||
|
style="text-decoration: none;"> |
||||||
|
<i class="fas fa-chevron-circle-down"></i> |
||||||
|
</a> |
||||||
|
<% end %> |
||||||
|
<h4 data-tokens-count class="ml-2 text-dark"><%= tokens_count_title(@tokens) %></h4> |
||||||
|
|
||||||
|
<div class="dropdown-menu p-0 token-balance-dropdown" aria-labelledby="dropdown-tokens"> |
||||||
|
<div data-dropdown-items class="dropdown-items"> |
||||||
|
<%= if Enum.any?(@tokens, & &1.type == "ERC-721") do %> |
||||||
|
<%= render( |
||||||
|
"_tokens.html", |
||||||
|
conn: @conn, |
||||||
|
tokens: filter_by_type(@tokens, "ERC-721"), |
||||||
|
type: "ERC-721" |
||||||
|
) %> |
||||||
|
<% end %> |
||||||
|
|
||||||
|
<%= if Enum.any?(@tokens, & &1.type == "ERC-20") do %> |
||||||
|
<%= render( |
||||||
|
"_tokens.html", |
||||||
|
conn: @conn, |
||||||
|
tokens: filter_by_type(@tokens, "ERC-20"), |
||||||
|
type: "ERC-20" |
||||||
|
) %> |
||||||
|
<% end %> |
||||||
|
</div> |
||||||
|
</div> |
@ -0,0 +1,17 @@ |
|||||||
|
<h6 class="dropdown-header border-bottom"> |
||||||
|
<%= @type %> (<%= Enum.count(@tokens)%>) |
||||||
|
</h6> |
||||||
|
|
||||||
|
<%= for token <- sort_by_name(@tokens) do %> |
||||||
|
<div class="border-bottom"> |
||||||
|
<%= link( |
||||||
|
to: token_path(@conn, :show, :en, token.contract_address_hash), |
||||||
|
class: "dropdown-item" |
||||||
|
) do %> |
||||||
|
<p class="mb-0"><%= token_name(token) %></p> |
||||||
|
<p class="mb-0"> |
||||||
|
<%= format_according_to_decimals(token.balance, token.decimals) %> <%= token.symbol %> |
||||||
|
</p> |
||||||
|
<% end %> |
||||||
|
</div> |
||||||
|
<% end %> |
@ -0,0 +1,20 @@ |
|||||||
|
defmodule BlockScoutWeb.AddressTokenBalanceView do |
||||||
|
use BlockScoutWeb, :view |
||||||
|
|
||||||
|
def tokens_count_title(tokens) do |
||||||
|
ngettext("%{count} token", "%{count} tokens", Enum.count(tokens)) |
||||||
|
end |
||||||
|
|
||||||
|
def filter_by_type(tokens, type) do |
||||||
|
Enum.filter(tokens, &(&1.type == type)) |
||||||
|
end |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Sorts the given list of tokens in alphabetically order considering nil values in the bottom of |
||||||
|
the list. |
||||||
|
""" |
||||||
|
def sort_by_name(tokens) do |
||||||
|
{unnamed, named} = Enum.split_with(tokens, &is_nil(&1.name)) |
||||||
|
Enum.sort_by(named, &String.downcase(&1.name)) ++ unnamed |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,30 @@ |
|||||||
|
defmodule BlockScoutWeb.AddressTokenBalanceViewTest do |
||||||
|
use BlockScoutWeb.ConnCase, async: true |
||||||
|
|
||||||
|
alias BlockScoutWeb.AddressTokenBalanceView |
||||||
|
|
||||||
|
describe "sort_by_name/1" do |
||||||
|
test "sorts the given tokens by its name" do |
||||||
|
token_a = build(:token, name: "token name") |
||||||
|
token_b = build(:token, name: "token") |
||||||
|
token_c = build(:token, name: "atoken") |
||||||
|
|
||||||
|
assert AddressTokenBalanceView.sort_by_name([token_a, token_b, token_c]) == [token_c, token_b, token_a] |
||||||
|
end |
||||||
|
|
||||||
|
test "considers nil values in the bottom of the list" do |
||||||
|
token_a = build(:token, name: nil) |
||||||
|
token_b = build(:token, name: "token name") |
||||||
|
token_c = build(:token, name: "token") |
||||||
|
|
||||||
|
assert AddressTokenBalanceView.sort_by_name([token_a, token_b, token_c]) == [token_c, token_b, token_a] |
||||||
|
end |
||||||
|
|
||||||
|
test "considers capitalization" do |
||||||
|
token_a = build(:token, name: "Token") |
||||||
|
token_b = build(:token, name: "atoken") |
||||||
|
|
||||||
|
assert AddressTokenBalanceView.sort_by_name([token_a, token_b]) == [token_b, token_a] |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,74 @@ |
|||||||
|
defmodule Explorer.Token.BalanceReader do |
||||||
|
@moduledoc """ |
||||||
|
Reads Token's balances using Smart Contract functions from the blockchain. |
||||||
|
""" |
||||||
|
|
||||||
|
alias Explorer.SmartContract.Reader |
||||||
|
|
||||||
|
@balance_function_abi [ |
||||||
|
%{ |
||||||
|
"type" => "function", |
||||||
|
"stateMutability" => "view", |
||||||
|
"payable" => false, |
||||||
|
"outputs" => [ |
||||||
|
%{ |
||||||
|
"type" => "uint256", |
||||||
|
"name" => "balance" |
||||||
|
} |
||||||
|
], |
||||||
|
"name" => "balanceOf", |
||||||
|
"inputs" => [ |
||||||
|
%{ |
||||||
|
"type" => "address", |
||||||
|
"name" => "tokenOwner" |
||||||
|
} |
||||||
|
], |
||||||
|
"constant" => true |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Fetches the token balances that were fetched without error and have balances more than 0. |
||||||
|
""" |
||||||
|
def fetch_token_balances_without_error(tokens, address_hash_string) do |
||||||
|
tokens |
||||||
|
|> fetch_token_balances_from_blockchain(address_hash_string) |
||||||
|
|> Stream.filter(&token_without_error?/1) |
||||||
|
|> Stream.map(&format_result/1) |
||||||
|
|> Enum.filter(&tokens_with_no_zero_balance?/1) |
||||||
|
end |
||||||
|
|
||||||
|
defp token_without_error?({:ok, _token}), do: true |
||||||
|
defp token_without_error?({:error, _token}), do: false |
||||||
|
defp format_result({:ok, token}), do: token |
||||||
|
defp tokens_with_no_zero_balance?(%{balance: balance}), do: balance != 0 |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Fetches the token balances given the tokens and the address hash as string. |
||||||
|
|
||||||
|
This function is going to perform one request async for each token inside a list of tokens in |
||||||
|
order to fetch the balance. |
||||||
|
""" |
||||||
|
@spec fetch_token_balances_from_blockchain([], String.t()) :: [] |
||||||
|
def fetch_token_balances_from_blockchain(tokens, address_hash_string) do |
||||||
|
tokens |
||||||
|
|> Task.async_stream(&fetch_from_blockchain(&1, address_hash_string)) |
||||||
|
|> Enum.map(&blockchain_result_from_tasks/1) |
||||||
|
end |
||||||
|
|
||||||
|
defp fetch_from_blockchain(%{contract_address_hash: address_hash} = token, address_hash_string) do |
||||||
|
address_hash |
||||||
|
|> Reader.query_unverified_contract(@balance_function_abi, %{"balanceOf" => [address_hash_string]}) |
||||||
|
|> format_blockchain_result(token) |
||||||
|
end |
||||||
|
|
||||||
|
defp format_blockchain_result(%{"balanceOf" => {:ok, balance}}, token) do |
||||||
|
{:ok, Map.put(token, :balance, balance)} |
||||||
|
end |
||||||
|
|
||||||
|
defp format_blockchain_result(%{"balanceOf" => {:error, error}}, token) do |
||||||
|
{:error, Map.put(token, :balance, error)} |
||||||
|
end |
||||||
|
|
||||||
|
defp blockchain_result_from_tasks({:ok, blockchain_result}), do: blockchain_result |
||||||
|
end |
@ -0,0 +1,130 @@ |
|||||||
|
defmodule Explorer.Token.BalanceReaderTest do |
||||||
|
use EthereumJSONRPC.Case |
||||||
|
use Explorer.DataCase |
||||||
|
|
||||||
|
doctest Explorer.Token.BalanceReader |
||||||
|
|
||||||
|
alias Explorer.Token.{BalanceReader} |
||||||
|
alias Explorer.Chain.Hash |
||||||
|
|
||||||
|
import Mox |
||||||
|
|
||||||
|
setup :verify_on_exit! |
||||||
|
setup :set_mox_global |
||||||
|
|
||||||
|
describe "fetch_token_balances_from_blockchain/2" do |
||||||
|
test "fetches balances of tokens given the address hash" do |
||||||
|
address = insert(:address) |
||||||
|
token = insert(:token, contract_address: build(:contract_address)) |
||||||
|
address_hash_string = Hash.to_string(address.hash) |
||||||
|
|
||||||
|
get_balance_from_blockchain() |
||||||
|
|
||||||
|
result = |
||||||
|
[token] |
||||||
|
|> BalanceReader.fetch_token_balances_from_blockchain(address_hash_string) |
||||||
|
|> List.first() |
||||||
|
|
||||||
|
assert result == {:ok, Map.put(token, :balance, 1_000_000_000_000_000_000_000_000)} |
||||||
|
end |
||||||
|
|
||||||
|
test "does not ignore calls that were returned with error" do |
||||||
|
address = insert(:address) |
||||||
|
token = insert(:token, contract_address: build(:contract_address)) |
||||||
|
address_hash_string = Hash.to_string(address.hash) |
||||||
|
|
||||||
|
get_balance_from_blockchain_with_error() |
||||||
|
|
||||||
|
result = |
||||||
|
[token] |
||||||
|
|> BalanceReader.fetch_token_balances_from_blockchain(address_hash_string) |
||||||
|
|> List.first() |
||||||
|
|
||||||
|
assert result == {:error, Map.put(token, :balance, "(-32015) VM execution error.")} |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "fetch_token_balances_without_error/2" do |
||||||
|
test "filters token balances that were fetched without error" do |
||||||
|
address = insert(:address) |
||||||
|
token_a = insert(:token, contract_address: build(:contract_address)) |
||||||
|
token_b = insert(:token, contract_address: build(:contract_address)) |
||||||
|
address_hash_string = Hash.to_string(address.hash) |
||||||
|
|
||||||
|
get_balance_from_blockchain() |
||||||
|
get_balance_from_blockchain_with_error() |
||||||
|
|
||||||
|
results = |
||||||
|
[token_a, token_b] |
||||||
|
|> BalanceReader.fetch_token_balances_without_error(address_hash_string) |
||||||
|
|
||||||
|
assert Enum.count(results) == 1 |
||||||
|
assert List.first(results) == Map.put(token_a, :balance, 1_000_000_000_000_000_000_000_000) |
||||||
|
end |
||||||
|
|
||||||
|
test "does not considers balances equal 0" do |
||||||
|
address = insert(:address) |
||||||
|
token = insert(:token, contract_address: build(:contract_address)) |
||||||
|
address_hash_string = Hash.to_string(address.hash) |
||||||
|
|
||||||
|
get_balance_from_blockchain_with_balance_zero() |
||||||
|
|
||||||
|
results = |
||||||
|
[token] |
||||||
|
|> BalanceReader.fetch_token_balances_without_error(address_hash_string) |
||||||
|
|
||||||
|
assert Enum.count(results) == 0 |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp get_balance_from_blockchain() do |
||||||
|
expect( |
||||||
|
EthereumJSONRPC.Mox, |
||||||
|
:json_rpc, |
||||||
|
fn [%{id: _, method: _, params: [%{data: _, to: _}]}], _options -> |
||||||
|
{:ok, |
||||||
|
[ |
||||||
|
%{ |
||||||
|
id: "balanceOf", |
||||||
|
jsonrpc: "2.0", |
||||||
|
result: "0x00000000000000000000000000000000000000000000d3c21bcecceda1000000" |
||||||
|
} |
||||||
|
]} |
||||||
|
end |
||||||
|
) |
||||||
|
end |
||||||
|
|
||||||
|
defp get_balance_from_blockchain_with_balance_zero() do |
||||||
|
expect( |
||||||
|
EthereumJSONRPC.Mox, |
||||||
|
:json_rpc, |
||||||
|
fn [%{id: _, method: _, params: [%{data: _, to: _}]}], _options -> |
||||||
|
{:ok, |
||||||
|
[ |
||||||
|
%{ |
||||||
|
id: "balanceOf", |
||||||
|
jsonrpc: "2.0", |
||||||
|
result: "0x0000000000000000000000000000000000000000000000000000000000000000" |
||||||
|
} |
||||||
|
]} |
||||||
|
end |
||||||
|
) |
||||||
|
end |
||||||
|
|
||||||
|
defp get_balance_from_blockchain_with_error() do |
||||||
|
expect( |
||||||
|
EthereumJSONRPC.Mox, |
||||||
|
:json_rpc, |
||||||
|
fn [%{id: _, method: _, params: [%{data: _, to: _}]}], _options -> |
||||||
|
{:ok, |
||||||
|
[ |
||||||
|
%{ |
||||||
|
error: %{code: -32015, data: "Reverted 0x", message: "VM execution error."}, |
||||||
|
id: "balanceOf", |
||||||
|
jsonrpc: "2.0" |
||||||
|
} |
||||||
|
]} |
||||||
|
end |
||||||
|
) |
||||||
|
end |
||||||
|
end |
Loading…
Reference in new issue