commit
b77d00a77a
@ -1,10 +1,15 @@ |
||||
<div class="tile tile-type-token"> |
||||
<div class="row justify-content"> |
||||
<div class="col-md-12 d-flex flex-column tile-label"> |
||||
<div class="row justify-content align-items-center"> |
||||
<div class="col-md-7 d-flex flex-column mt-3 mt-md-0"> |
||||
<%= link(to: token_path(@conn, :show, @token.contract_address_hash), class: "tile-title-lg") do %> |
||||
<%= token_name(@token) %> |
||||
<% end %> |
||||
<span><%= @token.type %> - <%= number_of_transfers(@token) %></span> |
||||
</div> |
||||
<div class="col-md-5 d-flex flex-column text-md-right mt-3 mt-md-0"> |
||||
<span class="tile-title-lg text-md-right align-bottom"> |
||||
<%= format_according_to_decimals(@token.balance, @token.decimals) %> <%= @token.symbol %> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
@ -0,0 +1,106 @@ |
||||
defmodule Explorer.Chain.Address.Token do |
||||
@moduledoc """ |
||||
A projection that represents the relation between a Token and a specific Address. |
||||
|
||||
This representation is expressed by the following attributes: |
||||
|
||||
- contract_address_hash - Address of a Token's contract. |
||||
- name - Token's name. |
||||
- symbol - Token's symbol. |
||||
- type - Token's type. |
||||
- decimals - Token's decimals. |
||||
- balance - how much tokens (TokenBalance) the Address has from the Token. |
||||
- transfer_count - a count of how many TokenTransfers of the Token the Address was involved. |
||||
""" |
||||
@enforce_keys [:contract_address_hash, :inserted_at, :name, :symbol, :balance, :decimals, :type, :transfers_count] |
||||
defstruct @enforce_keys |
||||
|
||||
import Ecto.Query |
||||
alias Explorer.{PagingOptions, Chain} |
||||
|
||||
alias Explorer.Chain.{Hash, Address, Address.TokenBalance} |
||||
|
||||
@default_paging_options %PagingOptions{page_size: 50} |
||||
@typep paging_options :: {:paging_options, PagingOptions.t()} |
||||
|
||||
@doc """ |
||||
It builds a paginated query of Address.Tokens that have a balance higher than zero ordered by type and name. |
||||
""" |
||||
@spec list_address_tokens_with_balance(Hash.t(), [paging_options()]) :: %Ecto.Query{} |
||||
def list_address_tokens_with_balance(address_hash, options \\ []) do |
||||
paging_options = Keyword.get(options, :paging_options, @default_paging_options) |
||||
|
||||
Chain.Token |
||||
|> Chain.Token.join_with_transfers() |
||||
|> join_with_last_balance(address_hash) |
||||
|> order_filter_and_group(address_hash) |
||||
|> page_tokens(paging_options) |
||||
|> limit(^paging_options.page_size) |
||||
end |
||||
|
||||
defp order_filter_and_group(query, address_hash) do |
||||
from( |
||||
[token, transfer, balance] in query, |
||||
order_by: fragment("? DESC, LOWER(?) ASC NULLS LAST", token.type, token.name), |
||||
where: |
||||
(transfer.to_address_hash == ^address_hash or transfer.from_address_hash == ^address_hash) and balance.value > 0, |
||||
group_by: [token.name, token.symbol, balance.value, token.type, token.contract_address_hash], |
||||
select: %Address.Token{ |
||||
contract_address_hash: token.contract_address_hash, |
||||
inserted_at: max(token.inserted_at), |
||||
name: token.name, |
||||
symbol: token.symbol, |
||||
balance: balance.value, |
||||
decimals: max(token.decimals), |
||||
type: token.type, |
||||
transfers_count: count(token.contract_address_hash) |
||||
} |
||||
) |
||||
end |
||||
|
||||
defp join_with_last_balance(queryable, address_hash) do |
||||
last_balance_query = |
||||
from( |
||||
tb in TokenBalance, |
||||
where: tb.address_hash == ^address_hash, |
||||
distinct: :token_contract_address_hash, |
||||
order_by: [desc: :block_number], |
||||
select: %{value: tb.value, token_contract_address_hash: tb.token_contract_address_hash} |
||||
) |
||||
|
||||
from( |
||||
t in queryable, |
||||
join: tb in subquery(last_balance_query), |
||||
on: tb.token_contract_address_hash == t.contract_address_hash |
||||
) |
||||
end |
||||
|
||||
@doc """ |
||||
Builds the pagination according to the given key within `PagingOptions`. |
||||
|
||||
* it just returns the given query when the key is nil. |
||||
* it composes another where clause considering the `type`, `name` and `inserted_at`. |
||||
|
||||
""" |
||||
def page_tokens(query, %PagingOptions{key: nil}), do: query |
||||
|
||||
def page_tokens(query, %PagingOptions{key: {nil, type, inserted_at}}) do |
||||
where( |
||||
query, |
||||
[token], |
||||
token.type < ^type or (token.type == ^type and is_nil(token.name) and token.inserted_at < ^inserted_at) |
||||
) |
||||
end |
||||
|
||||
def page_tokens(query, %PagingOptions{key: {name, type, inserted_at}}) do |
||||
upper_name = String.downcase(name) |
||||
|
||||
where( |
||||
query, |
||||
[token], |
||||
token.type < ^type or |
||||
(token.type == ^type and (fragment("LOWER(?)", token.name) > ^upper_name or is_nil(token.name))) or |
||||
(token.type == ^type and fragment("LOWER(?)", token.name) == ^upper_name and token.inserted_at < ^inserted_at) |
||||
) |
||||
end |
||||
end |
@ -0,0 +1,382 @@ |
||||
defmodule Explorer.Chain.Address.TokenTest do |
||||
use Explorer.DataCase |
||||
|
||||
alias Explorer.Repo |
||||
alias Explorer.Chain.Address |
||||
alias Explorer.Chain.Token |
||||
alias Explorer.PagingOptions |
||||
|
||||
describe "list_address_tokens_with_balance/2" do |
||||
test "returns tokens with number of transfers and balance value attached" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: "token-c", type: "ERC-721", decimals: 0, symbol: "TC") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: build(:address), |
||||
to_address: address |
||||
) |
||||
|
||||
fetched_token = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> List.first() |
||||
|
||||
assert fetched_token == %Explorer.Chain.Address.Token{ |
||||
contract_address_hash: token.contract_address_hash, |
||||
inserted_at: token.inserted_at, |
||||
name: "token-c", |
||||
symbol: "TC", |
||||
balance: Decimal.new(1000), |
||||
decimals: 0, |
||||
type: "ERC-721", |
||||
transfers_count: 2 |
||||
} |
||||
end |
||||
|
||||
test "returns tokens ordered by type in reverse alphabetical order" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: nil, type: "ERC-721", decimals: nil, symbol: nil) |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
token2 = |
||||
:token |
||||
|> insert(name: "token-c", type: "ERC-20", decimals: 0, symbol: "TC") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token2.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token2.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
fetched_tokens = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> Enum.map(& &1.contract_address_hash) |
||||
|
||||
assert fetched_tokens == [token.contract_address_hash, token2.contract_address_hash] |
||||
end |
||||
|
||||
test "returns tokens of same type by name in lowercase ascending" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: "atoken", type: "ERC-721", decimals: nil, symbol: nil) |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
token2 = |
||||
:token |
||||
|> insert(name: "1token-c", type: "ERC-721", decimals: 0, symbol: "TC") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token2.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token2.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
token3 = |
||||
:token |
||||
|> insert(name: "token-c", type: "ERC-721", decimals: 0, symbol: "TC") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token3.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token3.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
fetched_tokens = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> Enum.map(& &1.contract_address_hash) |
||||
|
||||
assert fetched_tokens == [token2.contract_address_hash, token.contract_address_hash, token3.contract_address_hash] |
||||
end |
||||
|
||||
test "returns tokens with null name after all the others of same type" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: nil, type: "ERC-721", decimals: nil, symbol: nil) |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
token2 = |
||||
:token |
||||
|> insert(name: "token-c", type: "ERC-721", decimals: 0, symbol: "TC") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token2.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token2.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
token3 = |
||||
:token |
||||
|> insert(name: "token-c", type: "ERC-721", decimals: 0, symbol: "TC") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token3.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token3.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
fetched_tokens = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> Enum.map(& &1.contract_address_hash) |
||||
|
||||
assert fetched_tokens == [token2.contract_address_hash, token3.contract_address_hash, token.contract_address_hash] |
||||
end |
||||
|
||||
test "does not return tokens with zero balance" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: "atoken", type: "ERC-721", decimals: 0, symbol: "AT") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 0 |
||||
) |
||||
|
||||
fetched_token = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> Enum.find(fn t -> t.name == "atoken" end) |
||||
|
||||
assert fetched_token == nil |
||||
end |
||||
|
||||
test "brings the value of the last balance" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: "atoken", type: "ERC-721", decimals: 0, symbol: "AT") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1234 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
fetched_token = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> List.first() |
||||
|
||||
assert fetched_token.balance == Decimal.new(1234) |
||||
end |
||||
|
||||
test "ignores token if the last balance is zero" do |
||||
address = insert(:address) |
||||
|
||||
token = |
||||
:token |
||||
|> insert(name: "atoken", type: "ERC-721", decimals: 0, symbol: "AT") |
||||
|> Repo.preload(:contract_address) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
insert( |
||||
:token_balance, |
||||
address: address, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 0 |
||||
) |
||||
|
||||
insert( |
||||
:token_transfer, |
||||
token_contract_address: token.contract_address, |
||||
from_address: address, |
||||
to_address: build(:address) |
||||
) |
||||
|
||||
fetched_token = |
||||
address.hash |
||||
|> Address.Token.list_address_tokens_with_balance() |
||||
|> Repo.all() |
||||
|> List.first() |
||||
|
||||
assert fetched_token == nil |
||||
end |
||||
end |
||||
|
||||
describe "page_tokens/2" do |
||||
test "just bring the normal query when PagingOptions.key is nil" do |
||||
options = %PagingOptions{key: nil} |
||||
|
||||
query = Ecto.Query.from(t in Token) |
||||
|
||||
assert Address.Token.page_tokens(query, options) == query |
||||
end |
||||
|
||||
test "add more conditions to the query when PagingOptions.key is not nil" do |
||||
token1 = insert(:token, name: "token-a", type: "ERC-20", decimals: 0, symbol: "TA") |
||||
|
||||
token2 = insert(:token, name: "token-c", type: "ERC-721", decimals: 0, symbol: "TC") |
||||
|
||||
options = %PagingOptions{key: {token2.name, token2.type, token2.inserted_at}} |
||||
|
||||
query = Ecto.Query.from(t in Token, order_by: t.type, preload: :contract_address) |
||||
|
||||
fetched_token = hd(Repo.all(Address.Token.page_tokens(query, options))) |
||||
refute Address.Token.page_tokens(query, options) == query |
||||
assert fetched_token == token1 |
||||
end |
||||
|
||||
test "tokens with nil name come after other tokens of same type" do |
||||
token1 = insert(:token, name: "token-a", type: "ERC-20", decimals: 0, symbol: "TA") |
||||
|
||||
token2 = insert(:token, name: nil, type: "ERC-20", decimals: 0, symbol: "TC") |
||||
|
||||
options = %PagingOptions{key: {token1.name, token1.type, token1.inserted_at}} |
||||
|
||||
query = Ecto.Query.from(t in Token, order_by: t.type, preload: :contract_address) |
||||
|
||||
fetched_token = hd(Repo.all(Address.Token.page_tokens(query, options))) |
||||
assert fetched_token == token2 |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue