diff --git a/apps/block_scout_web/assets/css/components/_tile.scss b/apps/block_scout_web/assets/css/components/_tile.scss index 9f07317c08..d7a83abdf0 100644 --- a/apps/block_scout_web/assets/css/components/_tile.scss +++ b/apps/block_scout_web/assets/css/components/_tile.scss @@ -63,6 +63,28 @@ } } + &-unique-token { + border-left: 4px solid $orange; + padding: 35px 0; + + .tile-label { + color: $orange; + } + } + + &-unique-token-image{ + border-left: 4px solid $orange; + padding: 0; + + .tile-label { + color: $orange; + } + + & .tile-content { + padding: 45px 0; + } + } + &-internal-transaction { border-left: 4px solid $teal; @@ -150,3 +172,8 @@ margin: 0; } } + +.tile-image { + max-width: 140px; + max-height: 140px; +} diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex index c41229acec..d65d11de7d 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -29,6 +29,10 @@ defmodule BlockScoutWeb.Chain do @page_size 50 @default_paging_options %PagingOptions{page_size: @page_size + 1} + def default_paging_options do + @default_paging_options + end + def current_filter(%{paging_options: paging_options} = params) do params |> Map.get("filter") diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex new file mode 100644 index 0000000000..a6a19a8523 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex @@ -0,0 +1,52 @@ +defmodule BlockScoutWeb.Tokens.InventoryController do + use BlockScoutWeb, :controller + + alias Explorer.Chain + alias Explorer.Chain.TokenTransfer + + import BlockScoutWeb.Chain, only: [split_list_by_page: 1, default_paging_options: 0] + + 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) do + unique_tokens = + Chain.address_to_unique_tokens( + token.contract_address_hash, + unique_tokens_paging_options(params) + ) + + {unique_tokens_paginated, next_page} = split_list_by_page(unique_tokens) + + render( + conn, + "index.html", + token: token, + unique_tokens: unique_tokens_paginated, + total_token_transfers: Chain.count_token_transfers_from_token_hash(address_hash), + total_token_holders: Chain.count_token_holders_from_token_hash(address_hash), + next_page_params: unique_tokens_next_page(next_page, unique_tokens_paginated, params) + ) + else + :error -> + not_found(conn) + + {:error, :not_found} -> + not_found(conn) + end + end + + defp unique_tokens_paging_options(%{"unique_token" => token_id}), + do: [paging_options: %{default_paging_options() | key: {token_id}}] + + defp unique_tokens_paging_options(_params), do: [paging_options: default_paging_options()] + + defp unique_tokens_next_page([], _list, _params), do: nil + + defp unique_tokens_next_page(_, list, params) do + Map.merge(params, paging_params(List.last(list))) + end + + defp paging_params(%TokenTransfer{token_id: token_id}) do + %{"unique_token" => Decimal.to_integer(token_id)} + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex index 93e6532cd3..cc18314f4b 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -135,6 +135,13 @@ defmodule BlockScoutWeb.Router do only: [:index], as: :holder ) + + resources( + "/inventory", + Tokens.InventoryController, + only: [:index], + as: :inventory + ) end resources( diff --git a/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/_token.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/_token.html.eex new file mode 100644 index 0000000000..88c36e5158 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/_token.html.eex @@ -0,0 +1,28 @@ + +
+
+
+ + <%= gettext "Unique Token" %> +
+ +
+ + <%= gettext "Token ID" %>: + + <%= @token_transfer.token_id %> + + + + + <%= gettext "Owner Address" %>: + + <%= render BlockScoutWeb.AddressView, + "_link.html", + address: @token_transfer.to_address, + contract: false %> + + +
+
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/index.html.eex new file mode 100644 index 0000000000..8b2fdcb5b7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/index.html.eex @@ -0,0 +1,42 @@ +
+ <%= render( + OverviewView, + "_details.html", + token: @token, + total_token_transfers: @total_token_transfers, + total_token_holders: @total_token_holders, + conn: @conn + ) %> + +
+
+
+ <%= render OverviewView, "_tabs.html", assigns %> +
+ +
+

<%= gettext "Inventory" %>

+ + <%= if Enum.any?(@unique_tokens) do %> + <%= for token_transfer <- @unique_tokens do %> + <%= render "_token.html", token_transfer: token_transfer %> + <% end %> + <% else %> +
+ + <%= gettext "There are no tokens." %> + +
+ <% end %> + + <%= if @next_page_params do %> + <%= link( + gettext("Next Page"), + class: "button button-secondary button-small float-right mt-4", + to: token_inventory_path(@conn, :index, @token.contract_address_hash, @next_page_params) + ) %> + <% end %> +
+
+
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex index b5dc0dc9b8..ac7d9fce2a 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex @@ -8,15 +8,6 @@ ) %> - <%= if TransferView.smart_contract_with_read_only_functions?(@token) do %> - - <% end %> - + + <%= if display_inventory?(@token) do %> + + <% end %> + + <%= if smart_contract_with_read_only_functions?(@token) do %> + + <% end %> @@ -45,17 +55,27 @@ class: "dropdown-item #{tab_status("token_transfers", @conn.request_path)}", to: token_path(@conn, :show, @token.contract_address_hash) ) %> - <%= if TransferView.smart_contract_with_read_only_functions?(@token) do %> - <%= link( - gettext("Read Contract"), - to: "#", - class: "dropdown-item #{tab_status("read_contract", @conn.request_path)}")%> - <% end %> + <%= link( gettext("Token Holders"), class: "dropdown-item #{tab_status("token_holders", @conn.request_path)}", to: token_holder_path(@conn, :index, @token.contract_address_hash) ) %> + + <%= if display_inventory?(@token) do %> + <%= link( + gettext("Inventory"), + class: "dropdown-item #{tab_status("inventory", @conn.request_path)}", + to: token_inventory_path(@conn, :index, @token.contract_address_hash) + ) %> + <% end %> + + <%= if smart_contract_with_read_only_functions?(@token) do %> + <%= link( + gettext("Read Contract"), + to: "#", + class: "dropdown-item #{tab_status("read_contract", @conn.request_path)}")%> + <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex new file mode 100644 index 0000000000..6c3f3a6845 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex @@ -0,0 +1,5 @@ +defmodule BlockScoutWeb.Tokens.InventoryView do + use BlockScoutWeb, :view + + alias BlockScoutWeb.Tokens.OverviewView +end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex index 80bb51a04e..f8fdfb867b 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex @@ -1,10 +1,9 @@ defmodule BlockScoutWeb.Tokens.OverviewView do use BlockScoutWeb, :view - alias Explorer.Chain.Token - alias BlockScoutWeb.Tokens.TransferView + alias Explorer.Chain.{Address, SmartContract, Token} - @tabs ["token_transfers", "token_holders", "read_contract"] + @tabs ["token_transfers", "token_holders", "read_contract", "inventory"] def decimals?(%Token{decimals: nil}), do: false def decimals?(%Token{decimals: _}), do: true @@ -34,4 +33,16 @@ defmodule BlockScoutWeb.Tokens.OverviewView do defp tab_name(["token_transfers"]), do: gettext("Token Transfers") defp tab_name(["token_holders"]), do: gettext("Token Holders") defp tab_name(["read_contract"]), do: gettext("Read Contract") + defp tab_name(["inventory"]), do: gettext("Inventory") + + def display_inventory?(%Token{type: "ERC-721"}), do: true + def display_inventory?(_), do: false + + def smart_contract_with_read_only_functions?( + %Token{contract_address: %Address{smart_contract: %SmartContract{}}} = token + ) do + Enum.any?(token.contract_address.smart_contract.abi, & &1["constant"]) + end + + def smart_contract_with_read_only_functions?(%Token{contract_address: %Address{smart_contract: nil}}), do: false end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex index 09944bcd03..cf002200e4 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex @@ -1,14 +1,5 @@ defmodule BlockScoutWeb.Tokens.TransferView do use BlockScoutWeb, :view - alias Explorer.Chain.{Address, SmartContract, Token} alias BlockScoutWeb.Tokens.OverviewView - - def smart_contract_with_read_only_functions?( - %Token{contract_address: %Address{smart_contract: %SmartContract{}}} = token - ) do - Enum.any?(token.contract_address.smart_contract.abi, & &1["constant"]) - end - - def smart_contract_with_read_only_functions?(%Token{contract_address: %Address{smart_contract: nil}}), do: false end diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index ddac06d944..ae5d77c836 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -270,6 +270,7 @@ msgid "TPM" msgstr "" #: lib/block_scout_web/templates/tokens/holder/index.html.eex:35 +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:34 msgid "Next Page" msgstr "" @@ -712,9 +713,9 @@ msgstr "" #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:100 #: lib/block_scout_web/templates/address_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_transaction/index.html.eex:91 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:14 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:50 -#: lib/block_scout_web/views/tokens/overview_view.ex:36 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:33 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:75 +#: lib/block_scout_web/views/tokens/overview_view.ex:35 msgid "Read Contract" msgstr "" @@ -816,12 +817,12 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:5 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:44 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:54 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:18 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:6 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:36 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:10 -#: lib/block_scout_web/views/tokens/overview_view.ex:34 +#: lib/block_scout_web/views/tokens/overview_view.ex:33 #: lib/block_scout_web/views/transaction_view.ex:169 msgid "Token Transfers" msgstr "" @@ -1090,9 +1091,9 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/tokens/holder/index.html.eex:19 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:22 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:55 -#: lib/block_scout_web/views/tokens/overview_view.ex:35 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:13 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:60 +#: lib/block_scout_web/views/tokens/overview_view.ex:34 msgid "Token Holders" msgstr "" @@ -1279,3 +1280,31 @@ msgstr "" #: lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex:26 msgid "Search tokens" msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:18 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:23 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:67 +#: lib/block_scout_web/views/tokens/overview_view.ex:36 +msgid "Inventory" +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:18 +msgid "Owner Address" +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:27 +msgid "There are no tokens." +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:11 +msgid "Token ID" +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:6 +msgid "Unique Token" +msgstr "" diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index 74e593ef62..015f397338 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -282,6 +282,7 @@ msgid "TPM" msgstr "" #: lib/block_scout_web/templates/tokens/holder/index.html.eex:35 +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:34 msgid "Next Page" msgstr "" @@ -724,9 +725,9 @@ msgstr "" #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:100 #: lib/block_scout_web/templates/address_transaction/index.html.eex:50 #: lib/block_scout_web/templates/address_transaction/index.html.eex:91 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:14 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:50 -#: lib/block_scout_web/views/tokens/overview_view.ex:36 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:33 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:75 +#: lib/block_scout_web/views/tokens/overview_view.ex:35 msgid "Read Contract" msgstr "" @@ -828,12 +829,12 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:5 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:44 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:54 #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:18 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:6 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:36 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:10 -#: lib/block_scout_web/views/tokens/overview_view.ex:34 +#: lib/block_scout_web/views/tokens/overview_view.ex:33 #: lib/block_scout_web/views/transaction_view.ex:169 msgid "Token Transfers" msgstr "" @@ -1102,9 +1103,9 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/tokens/holder/index.html.eex:19 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:22 -#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:55 -#: lib/block_scout_web/views/tokens/overview_view.ex:35 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:13 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:60 +#: lib/block_scout_web/views/tokens/overview_view.ex:34 msgid "Token Holders" msgstr "" @@ -1291,3 +1292,31 @@ msgstr "" #: lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex:26 msgid "Search tokens" msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:18 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:23 +#: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:67 +#: lib/block_scout_web/views/tokens/overview_view.ex:36 +msgid "Inventory" +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:18 +msgid "Owner Address" +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/index.html.eex:27 +msgid "There are no tokens." +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:11 +msgid "Token ID" +msgstr "" + +#, elixir-format +#: lib/block_scout_web/templates/tokens/inventory/_token.html.eex:6 +msgid "Unique Token" +msgstr "" diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs index 5f18eff56a..4ac295a710 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs @@ -64,4 +64,76 @@ defmodule BlockScoutWeb.Tokens.OverviewViewTest do assert OverviewView.current_tab_name(read_contract_path) == "Read Contract" end end + + describe "display_inventory?/1" do + test "returns true when token is unique" do + token = insert(:token, type: "ERC-721") + + assert OverviewView.display_inventory?(token) == true + end + + test "returns false when token is not unique" do + token = insert(:token, type: "ERC-20") + + assert OverviewView.display_inventory?(token) == false + end + end + + describe "smart_contract_with_read_only_functions?/1" do + test "returns true when abi has read only functions" do + smart_contract = + insert( + :smart_contract, + abi: [ + %{ + "constant" => true, + "inputs" => [], + "name" => "get", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + } + ] + ) + + address = insert(:address, smart_contract: smart_contract) + + token = insert(:token, contract_address: address) + + assert OverviewView.smart_contract_with_read_only_functions?(token) + end + + test "returns false when there is no read only functions" do + smart_contract = + insert( + :smart_contract, + abi: [ + %{ + "constant" => false, + "inputs" => [%{"name" => "x", "type" => "uint256"}], + "name" => "set", + "outputs" => [], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + ] + ) + + address = insert(:address, smart_contract: smart_contract) + + token = insert(:token, contract_address: address) + + refute OverviewView.smart_contract_with_read_only_functions?(token) + end + + test "returns false when smart contract is not verified" do + address = insert(:address, smart_contract: nil) + + token = insert(:token, contract_address: address) + + refute OverviewView.smart_contract_with_read_only_functions?(token) + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs index a8a1c726ce..147c34c861 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs @@ -2,62 +2,4 @@ defmodule BlockScoutWeb.Tokens.TransferViewTest do use BlockScoutWeb.ConnCase, async: true alias BlockScoutWeb.Tokens.TransferView - - describe "smart_contract_with_read_only_functions?/1" do - test "returns true when abi has read only functions" do - smart_contract = - insert( - :smart_contract, - abi: [ - %{ - "constant" => true, - "inputs" => [], - "name" => "get", - "outputs" => [%{"name" => "", "type" => "uint256"}], - "payable" => false, - "stateMutability" => "view", - "type" => "function" - } - ] - ) - - address = insert(:address, smart_contract: smart_contract) - - token = insert(:token, contract_address: address) - - assert TransferView.smart_contract_with_read_only_functions?(token) - end - - test "returns false when there is no read only functions" do - smart_contract = - insert( - :smart_contract, - abi: [ - %{ - "constant" => false, - "inputs" => [%{"name" => "x", "type" => "uint256"}], - "name" => "set", - "outputs" => [], - "payable" => false, - "stateMutability" => "nonpayable", - "type" => "function" - } - ] - ) - - address = insert(:address, smart_contract: smart_contract) - - token = insert(:token, contract_address: address) - - refute TransferView.smart_contract_with_read_only_functions?(token) - end - - test "returns false when smart contract is not verified" do - address = insert(:address, smart_contract: nil) - - token = insert(:token, contract_address: address) - - refute TransferView.smart_contract_with_read_only_functions?(token) - end - end end diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 88b4db01ed..71ed5ea09e 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -1868,4 +1868,15 @@ defmodule Explorer.Chain do |> TokenBalance.token_holders_from_token_hash() |> Repo.aggregate(:count, :address_hash) end + + @spec address_to_unique_tokens(Hash.Address.t(), [paging_options]) :: [TokenTransfer.t()] + def address_to_unique_tokens(contract_address_hash, options \\ []) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + contract_address_hash + |> TokenTransfer.address_to_unique_tokens() + |> TokenTransfer.page_token_transfer(paging_options) + |> limit(^paging_options.page_size) + |> Repo.all() + end end diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index 6ceed7d6a7..50d5b10a1b 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -26,7 +26,7 @@ defmodule Explorer.Chain.TokenTransfer do import Ecto.{Changeset, Query} - alias Explorer.Chain.{Address, Block, Hash, Transaction, TokenTransfer} + alias Explorer.Chain.{Address, Block, Hash, Transaction, Token, TokenTransfer} alias Explorer.{PagingOptions, Repo} @default_paging_options %PagingOptions{page_size: 50} @@ -141,6 +141,14 @@ defmodule Explorer.Chain.TokenTransfer do def page_token_transfer(query, %PagingOptions{key: nil}), do: query + def page_token_transfer(query, %PagingOptions{key: {token_id}}) do + where( + query, + [token_transfer], + token_transfer.token_id > ^token_id + ) + end + def page_token_transfer(query, %PagingOptions{key: inserted_at}) do where( query, @@ -166,4 +174,27 @@ defmodule Explorer.Chain.TokenTransfer do |> join(:left, [transaction], tt in assoc(transaction, :token_transfers)) |> where([_transaction, tt], tt.to_address_hash == ^address_hash or tt.from_address_hash == ^address_hash) end + + @doc """ + A token ERC-721 is considered unique because it corresponds to the possession + of a specific asset. + + To find out its current owner, it is necessary to look at the token last + transfer. + """ + @spec address_to_unique_tokens(Hash.Address.t()) :: %Ecto.Query{} + def address_to_unique_tokens(contract_address_hash) do + from( + tt in TokenTransfer, + join: t in Token, + on: tt.token_contract_address_hash == t.contract_address_hash, + join: ts in Transaction, + on: tt.transaction_hash == ts.hash, + where: t.contract_address_hash == ^contract_address_hash and t.type == "ERC-721", + order_by: [desc: ts.block_number], + distinct: tt.token_id, + preload: [:to_address], + select: tt + ) + end end diff --git a/apps/explorer/test/explorer/chain/token_transfer_test.exs b/apps/explorer/test/explorer/chain/token_transfer_test.exs index a0ec6cf378..8473dbeb51 100644 --- a/apps/explorer/test/explorer/chain/token_transfer_test.exs +++ b/apps/explorer/test/explorer/chain/token_transfer_test.exs @@ -3,7 +3,7 @@ defmodule Explorer.Chain.TokenTransferTest do import Explorer.Factory - alias Explorer.PagingOptions + alias Explorer.{PagingOptions, Repo} alias Explorer.Chain.TokenTransfer doctest Explorer.Chain.TokenTransfer @@ -142,4 +142,73 @@ defmodule Explorer.Chain.TokenTransferTest do assert TokenTransfer.count_token_transfers_from_token_hash(token_contract_address.hash) == 2 end end + + describe "address_to_unique_tokens/2" do + test "returns list of unique tokens for a token contract" do + token_contract_address = insert(:contract_address) + token = insert(:token, contract_address: token_contract_address, type: "ERC-721") + + transaction = + :transaction + |> insert() + |> with_block(insert(:block, number: 1)) + + insert( + :token_transfer, + to_address: build(:address), + transaction: transaction, + token_contract_address: token_contract_address, + token: token, + token_id: 42 + ) + + another_transaction = + :transaction + |> insert() + |> with_block(insert(:block, number: 2)) + + last_owner = + insert( + :token_transfer, + to_address: build(:address), + transaction: another_transaction, + token_contract_address: token_contract_address, + token: token, + token_id: 42 + ) + + results = + token_contract_address.hash + |> TokenTransfer.address_to_unique_tokens() + |> Repo.all() + + assert Enum.map(results, & &1.token_id) == [last_owner.token_id] + assert Enum.map(results, & &1.to_address_hash) == [last_owner.to_address_hash] + end + + test "won't return tokens that aren't uniques" do + token_contract_address = insert(:contract_address) + token = insert(:token, contract_address: token_contract_address, type: "ERC-20") + + transaction = + :transaction + |> insert() + |> with_block(insert(:block, number: 1)) + + insert( + :token_transfer, + to_address: build(:address), + transaction: transaction, + token_contract_address: token_contract_address, + token: token + ) + + results = + token_contract_address.hash + |> TokenTransfer.address_to_unique_tokens() + |> Repo.all() + + assert results == [] + end + end end diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index cc196e8af2..5e834724ef 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -2819,4 +2819,47 @@ defmodule Explorer.ChainTest do assert result == [transaction.hash] end end + + describe "address_to_unique_tokens/2" do + test "unique tokens can be paginated through token_id" do + token_contract_address = insert(:contract_address) + token = insert(:token, contract_address: token_contract_address, type: "ERC-721") + + transaction = + :transaction + |> insert() + |> with_block(insert(:block, number: 1)) + + first_page = + insert( + :token_transfer, + to_address: build(:address), + transaction: transaction, + token_contract_address: token_contract_address, + token: token, + token_id: 11 + ) + + second_page = + insert( + :token_transfer, + to_address: build(:address), + transaction: transaction, + token_contract_address: token_contract_address, + token: token, + token_id: 29 + ) + + paging_options = %PagingOptions{key: {first_page.token_id}, page_size: 1} + + unique_tokens_ids_paginated = + Chain.address_to_unique_tokens( + token_contract_address.hash, + paging_options: paging_options + ) + |> Enum.map(& &1.token_id) + + assert unique_tokens_ids_paginated == [second_page.token_id] + end + end end