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
+ ) %>
+
+
+
+
+
+
+
<%= 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 %>
-
- <%= link(
- gettext("Read Contract"),
- to: token_read_contract_path(@conn, :index, @token.contract_address_hash),
- class: "nav-link #{tab_status("read_contract", @conn.request_path)}")%>
-
- <% end %>
-
<%= link(
gettext("Token Holders"),
@@ -25,6 +16,25 @@
to: token_holder_path(@conn, :index, @token.contract_address_hash)
) %>
+
+ <%= if display_inventory?(@token) do %>
+
+ <%= link(
+ gettext("Inventory"),
+ class: "nav-link #{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: token_read_contract_path(@conn, :index, @token.contract_address_hash),
+ class: "nav-link #{tab_status("read_contract", @conn.request_path)}")%>
+
+ <% 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