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