diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0be27b5d..6bf8d00c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#6092](https://github.com/blockscout/blockscout/pull/6092) - Blockscout Account functionality - [#6073](https://github.com/blockscout/blockscout/pull/6073) - Add vyper support for rust verifier microservice integration - [#6111](https://github.com/blockscout/blockscout/pull/6111) - Add Prometheus metrics to indexer +- [#6168](https://github.com/blockscout/blockscout/pull/6168) - Token instance fetcher checks instance owner and updates current token balance ### Fixes diff --git a/apps/explorer/lib/explorer/token/instance_owner_reader.ex b/apps/explorer/lib/explorer/token/instance_owner_reader.ex new file mode 100644 index 0000000000..52b7477864 --- /dev/null +++ b/apps/explorer/lib/explorer/token/instance_owner_reader.ex @@ -0,0 +1,76 @@ +defmodule Explorer.Token.InstanceOwnerReader do + @moduledoc """ + Reads Token Instance owner using Smart Contract function from the blockchain. + """ + + require Logger + + alias Explorer.SmartContract.Reader + + @owner_function_signature "6352211e" + + @owner_function_abi [ + %{ + "type" => "function", + "stateMutability" => "view", + "payable" => false, + "outputs" => [ + %{ + "type" => "address", + "name" => "owner" + } + ], + "name" => "ownerOf", + "inputs" => [ + %{ + "type" => "uint256", + "name" => "tokenId" + } + ] + } + ] + + @spec get_owner_of([%{token_contract_address_hash: String.t(), token_id: integer}]) :: [ + {:ok, String.t()} | {:error, String.t()} + ] + def get_owner_of(instance_owner_requests) do + instance_owner_requests + |> Enum.map(&format_owner_request/1) + |> Reader.query_contracts(@owner_function_abi) + |> Enum.zip(instance_owner_requests) + |> Enum.reduce([], fn {result, request}, acc -> + case format_owner_result(result, request) do + {:ok, ok_result} -> + [ok_result] ++ acc + + {:error, error_message} -> + Logger.error( + "Failed to get owner of token #{request.token_contract_address_hash}, token_id #{request.token_id}, reason: #{error_message}" + ) + + acc + end + end) + end + + defp format_owner_request(%{token_contract_address_hash: token_contract_address_hash, token_id: token_id}) do + %{ + contract_address: token_contract_address_hash, + method_id: @owner_function_signature, + args: [token_id] + } + end + + defp format_owner_result({:ok, [owner]}, request) do + {:ok, + %{ + token_contract_address_hash: request.token_contract_address_hash, + token_id: request.token_id, + owner: owner + }} + end + + defp format_owner_result({:error, error_message}, _request) do + {:error, error_message} + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_instance.ex b/apps/indexer/lib/indexer/fetcher/token_instance.ex index ea2193727a..f489652400 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance.ex @@ -8,8 +8,9 @@ defmodule Indexer.Fetcher.TokenInstance do require Logger - alias Explorer.Chain - alias Explorer.Token.InstanceMetadataRetriever + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Address, Cache.BlockNumber, Token} + alias Explorer.Token.{InstanceMetadataRetriever, InstanceOwnerReader} alias Indexer.BufferedTask @behaviour BufferedTask @@ -59,6 +60,7 @@ defmodule Indexer.Fetcher.TokenInstance do end Enum.each(all_token_ids, &fetch_instance(hash, &1)) + update_current_token_balances(hash, all_token_ids) :ok end @@ -97,6 +99,57 @@ defmodule Indexer.Fetcher.TokenInstance do end end + defp update_current_token_balances(token_contract_address_hash, token_ids) do + token_ids + |> Enum.map(&instance_owner_request(token_contract_address_hash, &1)) + |> InstanceOwnerReader.get_owner_of() + |> Enum.map(¤t_token_balances_import_params/1) + |> all_import_params() + |> Chain.import() + end + + defp instance_owner_request(token_contract_address_hash, token_id) do + %{ + token_contract_address_hash: to_string(token_contract_address_hash), + token_id: Decimal.to_integer(token_id) + } + end + + defp current_token_balances_import_params(%{token_contract_address_hash: hash, token_id: token_id, owner: owner}) do + %{ + value: Decimal.new(1), + block_number: BlockNumber.get_max(), + value_fetched_at: DateTime.utc_now(), + token_id: token_id, + token_type: Repo.get_by(Token, contract_address_hash: hash).type, + address_hash: owner, + token_contract_address_hash: hash + } + end + + defp all_import_params(balances_import_params) do + addresses_import_params = + balances_import_params + |> Enum.reduce([], fn %{address_hash: address_hash}, acc -> + case Repo.get_by(Address, hash: address_hash) do + nil -> [%{hash: address_hash} | acc] + _address -> acc + end + end) + |> case do + [] -> %{} + params -> %{addresses: %{params: params}} + end + + current_token_balances_import_params = %{ + address_current_token_balances: %{ + params: balances_import_params + } + } + + Map.merge(current_token_balances_import_params, addresses_import_params) + end + @doc """ Fetches token instance data asynchronously. """ diff --git a/apps/indexer/test/indexer/fetcher/token_instance_test.exs b/apps/indexer/test/indexer/fetcher/token_instance_test.exs new file mode 100644 index 0000000000..d25ac501f1 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/token_instance_test.exs @@ -0,0 +1,87 @@ +defmodule Indexer.Fetcher.TokenInstanceTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import Mox + + alias Explorer.Chain + alias Explorer.Chain.Address + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Repo + alias Indexer.Fetcher.TokenInstance + + describe "run/2" do + test "updates current token balance" do + token = insert(:token, type: "ERC-1155") + token_contract_address_hash = token.contract_address_hash + instance = insert(:token_instance, token_contract_address_hash: token_contract_address_hash) + token_id = instance.token_id + address = insert(:address, hash: "0x57e93bb58268de818b42e3795c97bad58afcd3fe") + address_hash = address.hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [%{id: 0, method: "eth_call", params: [%{data: "0xc87b56dd" <> _}, _]}], _ -> + {:ok, + [ + %{ + id: 0, + result: + "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000027b7d000000000000000000000000000000000000000000000000000000000000" + } + ]} + end) + |> expect(:json_rpc, fn [%{id: 0, method: "eth_call", params: [%{data: "0x6352211e" <> _}, _]}], _ -> + {:ok, [%{id: 0, result: "0x00000000000000000000000057e93bb58268de818b42e3795c97bad58afcd3fe"}]} + end) + + TokenInstance.run( + [%{contract_address_hash: token_contract_address_hash, token_id: nil, token_ids: [token_id]}], + nil + ) + + assert %{ + token_id: ^token_id, + token_type: "ERC-1155", + token_contract_address_hash: ^token_contract_address_hash, + address_hash: ^address_hash + } = Repo.one(CurrentTokenBalance) + end + + test "updates current token balance with missing address" do + token = insert(:token, type: "ERC-1155") + token_contract_address_hash = token.contract_address_hash + instance = insert(:token_instance, token_contract_address_hash: token_contract_address_hash) + token_id = instance.token_id + {:ok, address_hash} = Chain.string_to_address_hash("0x57e93bb58268de818b42e3795c97bad58afcd3fe") + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [%{id: 0, method: "eth_call", params: [%{data: "0xc87b56dd" <> _}, _]}], _ -> + {:ok, + [ + %{ + id: 0, + result: + "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000027b7d000000000000000000000000000000000000000000000000000000000000" + } + ]} + end) + |> expect(:json_rpc, fn [%{id: 0, method: "eth_call", params: [%{data: "0x6352211e" <> _}, _]}], _ -> + {:ok, [%{id: 0, result: "0x00000000000000000000000057e93bb58268de818b42e3795c97bad58afcd3fe"}]} + end) + + TokenInstance.run( + [%{contract_address_hash: token_contract_address_hash, token_id: token_id, token_ids: nil}], + nil + ) + + assert %{ + token_id: ^token_id, + token_type: "ERC-1155", + token_contract_address_hash: ^token_contract_address_hash, + address_hash: ^address_hash + } = Repo.one(CurrentTokenBalance) + + assert %Address{} = Repo.get_by(Address, hash: address_hash) + end + end +end