Extend token instance metadata parsing functionality

pull/7031/head
Viktor Baranov 2 years ago
parent 87c61b3f18
commit 67ead3f217
  1. 2
      CHANGELOG.md
  2. 25
      apps/block_scout_web/lib/block_scout_web/views/nft_helpers.ex
  3. 28
      apps/block_scout_web/test/block_scout_web/views/nft_helpers_test.exs
  4. 132
      apps/explorer/lib/explorer/token/instance_metadata_retriever.ex
  5. 82
      apps/explorer/test/explorer/token/instance_metadata_retriever_test.exs

@ -7,7 +7,7 @@
### Fixes ### Fixes
- [#7008](https://github.com/blockscout/blockscout/pull/7008) - Fetch image/video content from IPFS link - [#7008](https://github.com/blockscout/blockscout/pull/7008) - Fetch image/video content from IPFS link
- [#7007](https://github.com/blockscout/blockscout/pull/7007) - Token instance fetcher fixes - [#7007](https://github.com/blockscout/blockscout/pull/7007), [#7031](https://github.com/blockscout/blockscout/pull/7031) - Token instance fetcher fixes
- [#7009](https://github.com/blockscout/blockscout/pull/7009) - Fix updating coin balances with empty value - [#7009](https://github.com/blockscout/blockscout/pull/7009) - Fix updating coin balances with empty value
### Chore ### Chore

@ -2,6 +2,8 @@ defmodule BlockScoutWeb.NFTHelpers do
@moduledoc """ @moduledoc """
Module with functions for NFT view Module with functions for NFT view
""" """
@ipfs_protocol "ipfs://"
def get_media_src(nil, _), do: nil def get_media_src(nil, _), do: nil
def get_media_src(metadata, high_quality_media?) do def get_media_src(metadata, high_quality_media?) do
@ -56,18 +58,29 @@ defmodule BlockScoutWeb.NFTHelpers do
|> compose_ipfs_url() |> compose_ipfs_url()
end end
def compose_ipfs_url(nil), do: nil
def compose_ipfs_url(image_url) do def compose_ipfs_url(image_url) do
image_url_downcase =
image_url
|> String.downcase()
cond do cond do
image_url =~ ~r/^ipfs:\/\/ipfs/ -> image_url_downcase =~ ~r/^ipfs:\/\/ipfs/ ->
"ipfs://ipfs" <> ipfs_uid = image_url prefix = @ipfs_protocol <> "ipfs/"
"https://ipfs.io/ipfs/" <> ipfs_uid ipfs_link(image_url, prefix)
image_url =~ ~r/^ipfs:\/\// -> image_url_downcase =~ ~r/^ipfs:\/\// ->
"ipfs://" <> ipfs_uid = image_url prefix = @ipfs_protocol
"https://ipfs.io/ipfs/" <> ipfs_uid ipfs_link(image_url, prefix)
true -> true ->
image_url image_url
end end
end end
defp ipfs_link(image_url, prefix) do
ipfs_uid = String.slice(image_url, String.length(prefix)..-1)
"https://ipfs.io/ipfs/" <> ipfs_uid
end
end end

@ -0,0 +1,28 @@
defmodule BlockScoutWeb.NFTHelpersTest do
use BlockScoutWeb.ConnCase, async: true
alias BlockScoutWeb.{NFTHelpers}
describe "compose_ipfs_url/1" do
test "transforms ipfs link like ipfs://${id}" do
url = "ipfs://QmYFf7D2UtqnNz8Lu57Gnk3dxgdAiuboPWMEaNNjhr29tS/hidden.png"
assert "https://ipfs.io/ipfs/QmYFf7D2UtqnNz8Lu57Gnk3dxgdAiuboPWMEaNNjhr29tS/hidden.png" ==
BlockScoutWeb.NFTHelpers.compose_ipfs_url(url)
end
test "transforms ipfs link like ipfs://ipfs" do
url = "ipfs://ipfs/Qmbgk4Ps5kiVdeYCHufMFgqzWLFuovFRtenY5P8m9vr9XW/animation.mp4"
assert "https://ipfs.io/ipfs/Qmbgk4Ps5kiVdeYCHufMFgqzWLFuovFRtenY5P8m9vr9XW/animation.mp4" ==
BlockScoutWeb.NFTHelpers.compose_ipfs_url(url)
end
test "transforms ipfs link in different case" do
url = "IpFs://baFybeid4ed2ua7fwupv4nx2ziczr3edhygl7ws3yx6y2juon7xakgj6cfm/51.json"
assert "https://ipfs.io/ipfs/baFybeid4ed2ua7fwupv4nx2ziczr3edhygl7ws3yx6y2juon7xakgj6cfm/51.json" ==
BlockScoutWeb.NFTHelpers.compose_ipfs_url(url)
end
end
end

@ -59,6 +59,8 @@ defmodule Explorer.Token.InstanceMetadataRetriever do
@no_uri_error "no uri" @no_uri_error "no uri"
@vm_execution_error "VM execution error" @vm_execution_error "VM execution error"
@ipfs_protocol "ipfs://"
@ipfs_link "https://ipfs.io/ipfs/"
# https://eips.ethereum.org/EIPS/eip-1155#metadata # https://eips.ethereum.org/EIPS/eip-1155#metadata
@erc1155_token_id_placeholder "{id}" @erc1155_token_id_placeholder "{id}"
@ -100,65 +102,64 @@ defmodule Explorer.Token.InstanceMetadataRetriever do
{:ok, %{error: @no_uri_error}} {:ok, %{error: @no_uri_error}}
end end
def fetch_json(uri, _hex_token_id) def fetch_json(%{@token_uri => uri}, hex_token_id) do
when uri in [ fetch_json_from_uri(uri, hex_token_id)
%{@token_uri => {:error, "(-32015) VM execution error."}},
%{@uri => {:error, "(-32015) VM execution error."}},
%{@token_uri => {:error, "(-32000) execution reverted"}},
%{@uri => {:error, "(-32000) execution reverted"}}
] do
{:ok, %{error: @vm_execution_error}}
end end
def fetch_json(%{@token_uri => {:error, "(-32015) VM execution error." <> _}}, _hex_token_id) do def fetch_json(%{@uri => uri}, hex_token_id) do
{:ok, %{error: @vm_execution_error}} fetch_json_from_uri(uri, hex_token_id)
end end
def fetch_json(%{@uri => {:error, "(-32015) VM execution error." <> _}}, _hex_token_id) do # CIDv0 IPFS links # https://docs.ipfs.tech/concepts/content-addressing/#version-0-v0
{:ok, %{error: @vm_execution_error}} def fetch_json("Qm" <> _ = result, hex_token_id) do
end if String.length(result) == 46 do
fetch_json_from_uri({:ok, [@ipfs_link <> result]}, hex_token_id)
else
Logger.debug(["Unknown metadata format result #{inspect(result)}."], fetcher: :token_instances)
def fetch_json(%{@token_uri => {:error, "(-32000) execution reverted" <> _}}, _hex_token_id) do {:error, result}
{:ok, %{error: @vm_execution_error}} end
end end
def fetch_json(%{@uri => {:error, "(-32000) execution reverted" <> _}}, _hex_token_id) do def fetch_json(result, hex_token_id) do
{:ok, %{error: @vm_execution_error}} case URI.parse(result) do
end %URI{host: nil} ->
Logger.debug(["Unknown metadata format #{inspect(result)}."], fetcher: :token_instances)
def fetch_json(%{@token_uri => {:ok, ["http://" <> _ = token_uri]}}, hex_token_id) do {:error, result}
fetch_metadata_inner(token_uri, hex_token_id)
_ ->
fetch_json_from_uri({:ok, [result]}, hex_token_id)
end
end end
def fetch_json(%{@uri => {:ok, ["http://" <> _ = token_uri]}}, hex_token_id) do defp fetch_json_from_uri({:error, error}, _hex_token_id) do
fetch_metadata_inner(token_uri, hex_token_id) if error =~ "execution reverted" or error =~ @vm_execution_error do
{:ok, %{error: @vm_execution_error}}
else
Logger.debug(["Unknown metadata format error #{inspect(error)}."], fetcher: :token_instances)
{:error, error}
end
end end
def fetch_json(%{@token_uri => {:ok, ["https://" <> _ = token_uri]}}, hex_token_id) do defp fetch_json_from_uri({:ok, ["'" <> token_uri]}, hex_token_id) do
token_uri = token_uri |> String.split("'") |> List.first()
fetch_metadata_inner(token_uri, hex_token_id) fetch_metadata_inner(token_uri, hex_token_id)
end end
def fetch_json(%{@uri => {:ok, ["https://" <> _ = token_uri]}}, hex_token_id) do defp fetch_json_from_uri({:ok, ["http://" <> _ = token_uri]}, hex_token_id) do
fetch_metadata_inner(token_uri, hex_token_id) fetch_metadata_inner(token_uri, hex_token_id)
end end
def fetch_json(%{@token_uri => {:ok, ["data:application/json," <> json]}}, hex_token_id) do defp fetch_json_from_uri({:ok, ["https://" <> _ = token_uri]}, hex_token_id) do
decoded_json = URI.decode(json) fetch_metadata_inner(token_uri, hex_token_id)
fetch_json(%{@token_uri => {:ok, [decoded_json]}}, hex_token_id)
rescue
e ->
Logger.debug(["Unknown metadata format #{inspect(json)}.", Exception.format(:error, e, __STACKTRACE__)],
fetcher: :token_instances
)
{:error, json}
end end
def fetch_json(%{@uri => {:ok, ["data:application/json," <> json]}}, hex_token_id) do defp fetch_json_from_uri({:ok, ["data:application/json," <> json]}, hex_token_id) do
decoded_json = URI.decode(json) decoded_json = URI.decode(json)
fetch_json(%{@uri => {:ok, [decoded_json]}}, hex_token_id) fetch_json_from_uri({:ok, [decoded_json]}, hex_token_id)
rescue rescue
e -> e ->
Logger.debug(["Unknown metadata format #{inspect(json)}.", Exception.format(:error, e, __STACKTRACE__)], Logger.debug(["Unknown metadata format #{inspect(json)}.", Exception.format(:error, e, __STACKTRACE__)],
@ -168,10 +169,10 @@ defmodule Explorer.Token.InstanceMetadataRetriever do
{:error, json} {:error, json}
end end
def fetch_json(%{@token_uri => {:ok, ["data:application/json;base64," <> base64_encoded_json]}}, hex_token_id) do defp fetch_json_from_uri({:ok, ["data:application/json;base64," <> base64_encoded_json]}, hex_token_id) do
case Base.decode64(base64_encoded_json) do case Base.decode64(base64_encoded_json) do
{:ok, base64_decoded} -> {:ok, base64_decoded} ->
fetch_json(%{@token_uri => {:ok, [base64_decoded]}}, hex_token_id) fetch_json_from_uri({:ok, [base64_decoded]}, hex_token_id)
_ -> _ ->
{:error, base64_encoded_json} {:error, base64_encoded_json}
@ -189,54 +190,15 @@ defmodule Explorer.Token.InstanceMetadataRetriever do
{:error, base64_encoded_json} {:error, base64_encoded_json}
end end
def fetch_json(%{@uri => {:ok, ["data:application/json;base64," <> base64_encoded_json]}}, hex_token_id) do defp fetch_json_from_uri({:ok, ["#{@ipfs_protocol}ipfs/" <> right]}, hex_token_id) do
case Base.decode64(base64_encoded_json) do
{:ok, base64_decoded} ->
fetch_json(%{@uri => {:ok, [base64_decoded]}}, hex_token_id)
_ ->
{:error, base64_encoded_json}
end
rescue
e ->
Logger.debug(
["Unknown metadata format base64 #{inspect(base64_encoded_json)}", Exception.format(:error, e, __STACKTRACE__)],
fetcher: :token_instances
)
{:error, base64_encoded_json}
end
def fetch_json(%{@token_uri => {:ok, ["ipfs://ipfs/" <> right]}}, hex_token_id) do
fetch_from_ipfs(right, hex_token_id) fetch_from_ipfs(right, hex_token_id)
end end
def fetch_json(%{@uri => {:ok, ["ipfs://ipfs/" <> right]}}, hex_token_id) do defp fetch_json_from_uri({:ok, [@ipfs_protocol <> right]}, hex_token_id) do
fetch_from_ipfs(right, hex_token_id) fetch_from_ipfs(right, hex_token_id)
end end
def fetch_json(%{@token_uri => {:ok, ["ipfs://" <> right]}}, hex_token_id) do defp fetch_json_from_uri({:ok, [json]}, hex_token_id) do
fetch_from_ipfs(right, hex_token_id)
end
def fetch_json(%{@uri => {:ok, ["ipfs://" <> right]}}, hex_token_id) do
fetch_from_ipfs(right, hex_token_id)
end
def fetch_json(%{@token_uri => {:ok, [json]}}, hex_token_id) do
{:ok, json} = decode_json(json)
check_type(json, hex_token_id)
rescue
e ->
Logger.debug(["Unknown metadata format #{inspect(json)}.", Exception.format(:error, e, __STACKTRACE__)],
fetcher: :token_instances
)
{:error, json}
end
def fetch_json(%{@uri => {:ok, [json]}}, hex_token_id) do
{:ok, json} = decode_json(json) {:ok, json} = decode_json(json)
check_type(json, hex_token_id) check_type(json, hex_token_id)
@ -249,14 +211,14 @@ defmodule Explorer.Token.InstanceMetadataRetriever do
{:error, json} {:error, json}
end end
def fetch_json(result, _hex_token_id) do defp fetch_json_from_uri(uri, _hex_token_id) do
Logger.debug(["Unknown metadata format #{inspect(result)}."], fetcher: :token_instances) Logger.debug(["Unknown metadata uri format #{inspect(uri)}."], fetcher: :token_instances)
{:error, result} {:error, uri}
end end
defp fetch_from_ipfs(ipfs_uid, hex_token_id) do defp fetch_from_ipfs(ipfs_uid, hex_token_id) do
ipfs_url = "https://ipfs.io/ipfs/" <> ipfs_uid ipfs_url = @ipfs_link <> ipfs_uid
fetch_metadata_inner(ipfs_url, hex_token_id) fetch_metadata_inner(ipfs_url, hex_token_id)
end end

@ -361,7 +361,7 @@ defmodule Explorer.Token.InstanceMetadataRetrieverTest do
metadata: %{ metadata: %{
"image" => "https://ipfs.io/ipfs/bafybeig6nlmyzui7llhauc52j2xo5hoy4lzp6442lkve5wysdvjkizxonu" "image" => "https://ipfs.io/ipfs/bafybeig6nlmyzui7llhauc52j2xo5hoy4lzp6442lkve5wysdvjkizxonu"
} }
}} = InstanceMetadataRetriever.fetch_json(data) }} == InstanceMetadataRetriever.fetch_json(data)
end end
test "Fetches metadata from ipfs" do test "Fetches metadata from ipfs" do
@ -373,16 +373,84 @@ defmodule Explorer.Token.InstanceMetadataRetrieverTest do
]} ]}
} }
{:ok,
%{
metadata: metadata
}} = InstanceMetadataRetriever.fetch_json(data)
assert "ipfs://bafybeihxuj3gxk7x5p36amzootyukbugmx3pw7dyntsrohg3se64efkuga/51.png" == Map.get(metadata, "image")
end
test "Fetches metadata from '${url}'" do
data = %{
"c87b56dd" =>
{:ok,
[
"'https://cards.collecttrumpcards.com/data/8/8578.json'"
]}
}
assert {:ok,
%{
metadata: %{
"attributes" => [
%{"trait_type" => "Character", "value" => "Blue Suit Boxing Glove"},
%{"trait_type" => "Face", "value" => "Wink"},
%{"trait_type" => "Hat", "value" => "Blue"},
%{"trait_type" => "Background", "value" => "Red Carpet"}
],
"image" => "https://cards.collecttrumpcards.com/cards/0c68b1ab6.jpg",
"name" => "Trump Digital Trading Card #8578",
"tokenId" => 8578
}
}} == InstanceMetadataRetriever.fetch_json(data)
end
test "Process custom execution reverted" do
data = %{
"c87b56dd" =>
{:error,
"(3) execution reverted: Nonexistent token (0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000114e6f6e6578697374656e7420746f6b656e000000000000000000000000000000)"}
}
assert {:ok, %{error: "VM execution error"}} == InstanceMetadataRetriever.fetch_json(data)
end
test "Process CIDv0 IPFS links" do
data = "QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP"
assert {:ok,
%{
metadata: %{
"collectionId" => "1871_1665123820823",
"description" => "asda",
"img_hash" => "QmUfW3PVnh9GGuHcQgc3ZeNEbhwp5HE8rS5ac9MDWWQebz",
"name" => "asda",
"salePrice" => 34
}
}} == InstanceMetadataRetriever.fetch_json(data)
end
test "Process URI directly from link" do
data = "https://dejob.io/api/dejobio/v1/nftproduct/1"
assert {:ok, assert {:ok,
%{ %{
metadata: %{ metadata: %{
"image" => "ipfs://bafybeihxuj3gxk7x5p36amzootyukbugmx3pw7dyntsrohg3se64efkuga/51.png", "description" =>
"attributes" => _, "\\\"Blue Reign: The Dragon Football Champion of the Floral City\\\" is a science fiction story about a dragon who loves playing football and dreams of becoming a champion. The story takes place in a futuristic city full of flowers and blue light, and it is raining throughout the story.\r\n\r\nThroughout the story, the dragon faces challenges on and off the field, including intense training regimens, rival teams, and personal struggles. He perseveres through these obstacles and incorporates new techniques and strategies into his gameplay.\r\n\r\nAs the playoffs approach, the dragon\\'s team faces increasingly tough opponents, culminating in a highly anticipated championship game against their long-standing rivals, the Storm Hawks. The dragon\\'s heart-pumping performance and his team\\'s impressive plays lead them to victory, and they celebrate their status as champions.\r\n\r\nThe story ultimately focuses on the dragon\\'s journey towards achieving his dream and the teamwork and dedication required to succeed in a highly competitive sport.",
"description" => "No roadmap Just OP NOK...But This NFT can use in Sobta ecosystem (if any)", "name" => "Blue Reign: The Dragon Football Champion of the Floral City",
"edition" => 51, "attributes" => [
"name" => "SobtaOpGenesis #51" %{"trait_type" => "Product Type", "value" => "Book"},
%{"display_type" => "number", "trait_type" => "Total Sold", "value" => "0"},
%{"display_type" => "number", "trait_type" => "Success Sold", "value" => "0"},
%{"max_value" => "100", "trait_type" => "Success Rate", "value" => "0"}
],
"external_url" => "https://dejob.io/?p=49",
"image" =>
"https://cdn.discordapp.com/attachments/1008567215739650078/1080111780858187796/savechives_a_dragon_playing_football_in_a_city_full_of_flowers__0739cc42-aae1-4909-a964-3f9c0ed1a9ed.png"
} }
}} = InstanceMetadataRetriever.fetch_json(data) }} == InstanceMetadataRetriever.fetch_json(data)
end end
end end
end end

Loading…
Cancel
Save