commit
8c3605dd28
@ -0,0 +1,6 @@ |
||||
$btn-contract-color: $primary !default; |
||||
$btn-contract-dimensions: 31px !default; |
||||
|
||||
.btn-contract-icon { |
||||
@include square-icon-button($btn-contract-color, $btn-contract-dimensions); |
||||
} |
@ -0,0 +1,10 @@ |
||||
/* ERC721 image block */ |
||||
.erc721-image { |
||||
display: flex; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.erc721-image img { |
||||
height: 150px; |
||||
} |
||||
/* ERC721 image block end */ |
After Width: | Height: | Size: 4.2 KiB |
@ -0,0 +1,34 @@ |
||||
defmodule BlockScoutWeb.Tokens.Instance.MetadataController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
alias Explorer.{Chain, Market} |
||||
|
||||
def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id}) do |
||||
options = [necessity_by_association: %{[contract_address: :smart_contract] => :optional}] |
||||
|
||||
with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), |
||||
{:ok, token} <- Chain.token_from_address_hash(hash, options), |
||||
{:ok, token_transfer} <- |
||||
Chain.erc721_token_instance_from_token_id_and_token_address(token_id, hash) do |
||||
if token_transfer.instance && token_transfer.instance.metadata do |
||||
render( |
||||
conn, |
||||
"index.html", |
||||
token_instance: token_transfer, |
||||
current_path: current_path(conn), |
||||
token: Market.add_price(token), |
||||
total_token_transfers: Chain.count_token_transfers_from_token_hash_and_token_id(hash, token_id) |
||||
) |
||||
else |
||||
not_found(conn) |
||||
end |
||||
else |
||||
_ -> |
||||
not_found(conn) |
||||
end |
||||
end |
||||
|
||||
def index(conn, _) do |
||||
not_found(conn) |
||||
end |
||||
end |
@ -0,0 +1,74 @@ |
||||
defmodule BlockScoutWeb.Tokens.Instance.TransferController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
alias BlockScoutWeb.Tokens.TransferView |
||||
alias Explorer.{Chain, Market} |
||||
alias Phoenix.View |
||||
|
||||
import BlockScoutWeb.Chain, only: [split_list_by_page: 1, paging_options: 1, next_page_params: 3] |
||||
|
||||
def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id, "type" => "JSON"} = params) do |
||||
with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), |
||||
{:ok, token} <- Chain.token_from_address_hash(hash), |
||||
token_transfers <- |
||||
Chain.fetch_token_transfers_from_token_hash_and_token_id(hash, token_id, paging_options(params)) do |
||||
{token_transfers_paginated, next_page} = split_list_by_page(token_transfers) |
||||
|
||||
next_page_path = |
||||
case next_page_params(next_page, token_transfers_paginated, params) do |
||||
nil -> |
||||
nil |
||||
|
||||
next_page_params -> |
||||
token_instance_transfer_path( |
||||
conn, |
||||
:index, |
||||
token_id, |
||||
token.contract_address_hash, |
||||
Map.delete(next_page_params, "type") |
||||
) |
||||
end |
||||
|
||||
transfers_json = |
||||
Enum.map(token_transfers_paginated, fn transfer -> |
||||
View.render_to_string( |
||||
TransferView, |
||||
"_token_transfer.html", |
||||
conn: conn, |
||||
token: token, |
||||
token_transfer: transfer |
||||
) |
||||
end) |
||||
|
||||
json(conn, %{items: transfers_json, next_page_path: next_page_path}) |
||||
else |
||||
_ -> |
||||
not_found(conn) |
||||
end |
||||
end |
||||
|
||||
def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id}) do |
||||
options = [necessity_by_association: %{[contract_address: :smart_contract] => :optional}] |
||||
|
||||
with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), |
||||
{:ok, token} <- Chain.token_from_address_hash(hash, options), |
||||
{:ok, token_transfer} <- |
||||
Chain.erc721_token_instance_from_token_id_and_token_address(token_id, hash) do |
||||
render( |
||||
conn, |
||||
"index.html", |
||||
token_instance: token_transfer, |
||||
current_path: current_path(conn), |
||||
token: Market.add_price(token), |
||||
total_token_transfers: Chain.count_token_transfers_from_token_hash_and_token_id(hash, token_id) |
||||
) |
||||
else |
||||
_ -> |
||||
not_found(conn) |
||||
end |
||||
end |
||||
|
||||
def index(conn, _) do |
||||
not_found(conn) |
||||
end |
||||
end |
@ -0,0 +1,20 @@ |
||||
defmodule BlockScoutWeb.Tokens.InstanceController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
alias Explorer.Chain |
||||
|
||||
def show(conn, %{"token_id" => token_address_hash, "id" => token_id}) do |
||||
with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), |
||||
:ok <- Chain.check_token_exists(hash), |
||||
:ok <- Chain.check_erc721_token_instance_exists(token_id, hash) do |
||||
redirect(conn, to: token_instance_transfer_path(conn, :index, token_address_hash, token_id)) |
||||
else |
||||
_ -> |
||||
not_found(conn) |
||||
end |
||||
end |
||||
|
||||
def show(conn, _) do |
||||
not_found(conn) |
||||
end |
||||
end |
@ -0,0 +1,30 @@ |
||||
<section class="container"> |
||||
<%= render( |
||||
OverviewView, |
||||
"_details.html", |
||||
token: @token, |
||||
total_token_transfers: @total_token_transfers, |
||||
token_id: @token_instance.token_id, |
||||
token_instance: @token_instance, |
||||
conn: @conn |
||||
) %> |
||||
|
||||
<section> |
||||
<div class="card"> |
||||
<%= render OverviewView, "_tabs.html", assigns %> |
||||
<div class="card-body"> |
||||
<section> |
||||
<div class="d-flex justify-content-between align-items-baseline"> |
||||
<h3><%= gettext "Metadata" %></h3> |
||||
<button type="button" class="btn-line" id="button" data-clipboard-text="<%= format_metadata(@token_instance.instance.metadata) %>" aria-label="Copy Metadata"> |
||||
<%= gettext "Copy Metadata" %> |
||||
</button> |
||||
</div> |
||||
<div class="tile tile-muted mb-4"> |
||||
<pre class="pre-wrap pre-scrollable"><code class="nohighlight"><%= format_metadata(@token_instance.instance.metadata) %></code> |
||||
</pre> |
||||
</div> |
||||
</section> |
||||
</div> |
||||
</section> |
||||
</section> |
@ -0,0 +1,106 @@ |
||||
<section class="address-overview"> |
||||
<div class="row"> |
||||
<div class="card-section col-md-12 col-lg-7 pr-0-md"> |
||||
<div class="card"> |
||||
<div class="card-body"> |
||||
<h1 class="card-title"> |
||||
<%= if token_name?(@token) do %> |
||||
<%= @token.name %> |
||||
<% else %> |
||||
<%= gettext("Token Details") %> |
||||
<% end %> |
||||
<!-- buttons --> |
||||
<span class="overview-title-buttons float-right"> |
||||
<span class="overview-title-item"> |
||||
<span |
||||
aria-label='<%= gettext("View Contract") %>' |
||||
class="btn-contract-icon" |
||||
data-placement="top" |
||||
data-toggle="tooltip" |
||||
title='<%= gettext("View Contract") %>' |
||||
onclick='<%= "location='#{address_path(@conn, :show, @token.contract_address_hash)}'" %>' |
||||
> |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32.5 32.5" width="32" height="32" transform="translate(8,8)"> |
||||
<path fill-rule="evenodd" d="M15 16H1a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1zM14 2H2v12h12V2z"/> |
||||
<path fill-rule="evenodd" d="M11 9h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm0-3H5a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2zM5 7h1a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zm0 3h6a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2z"/> |
||||
</svg> |
||||
</span> |
||||
</span> |
||||
<span class="overview-title-item" data-clipboard-text="<%= @token_id %>"> |
||||
<span |
||||
aria-label='<%= gettext("Copy Token ID") %>' |
||||
class="btn-copy-icon" |
||||
data-placement="top" |
||||
data-toggle="tooltip" |
||||
title='<%= gettext("Copy Token ID") %>' |
||||
> |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32.5 32.5" width="32" height="32"> |
||||
<path fill-rule="evenodd" d="M23.5 20.5a1 1 0 0 1-1-1v-9h-9a1 1 0 0 1 0-2h10a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zm-3-7v10a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-10a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1zm-2 1h-8v8h8v-8z"/> |
||||
</svg> |
||||
</span> |
||||
</span> |
||||
<span |
||||
class="overview-title-item" |
||||
data-target="#qrModal" |
||||
data-toggle="modal" |
||||
> |
||||
<span |
||||
class="btn-qr-icon" |
||||
data-toggle="tooltip" |
||||
data-placement="top" |
||||
title='<%= gettext("QR Code") %>' |
||||
aria-label='<%= gettext("Show QR Code") %>' |
||||
> |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32.5 32.5" width="32" height="32"> |
||||
<path fill-rule="evenodd" d="M22.5 24.5v-2h2v2h-2zm-1-4v-1h1v1h-1zm1-3h2v2h-2v-2zm1-2h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1zm-1-5h-3v3h3v-3zm-8 14h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1zm-1-5h-3v3h3v-3zm1-4h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1zm-1-5h-3v3h3v-3zm6 9h-2v-2h2v2zm1 1h-1v-1h1v1zm0 1v1h-1v-1h1zm-1 3h-2v-2h2v2z"/> |
||||
</svg> |
||||
</span> |
||||
</span> |
||||
</span> |
||||
</h1> |
||||
|
||||
<h3><%= to_string(@token_id) %></h3> |
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-start text-muted"> |
||||
<div class="d-flex flex-row justify-content-start text-muted"> |
||||
<span class="mr-4"> <%= @token.type %> </span> |
||||
<span class="mr-4"><%= @total_token_transfers %> <%= gettext "Transfers" %></span> |
||||
<%= if decimals?(@token) do %> |
||||
<span class="mr-4"><%= @token.decimals %> <%= gettext "Decimals" %></span> |
||||
<% end %> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="card-section col-md-12 col-lg-5 pl-0-md"> |
||||
<div class="card"> |
||||
<div class="card-body"> |
||||
<div class="erc721-image" > |
||||
<img src=<%=image_src(@token_instance.instance)%> /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</section> |
||||
<!-- Modal --> |
||||
<div class="modal fade" id="qrModal" tabindex="-1" role="dialog" aria-labelledby="qrModalLabel" aria-hidden="true"> |
||||
<div class="modal-dialog modal-sm" role="document"> |
||||
<div class="modal-content"> |
||||
<div class="modal-header"> |
||||
<h2 class="modal-title" id="qrModalLabel"><%= gettext "QR Code" %></h2> |
||||
<button type="button" class="close" data-dismiss="modal" aria-label="<%= gettext("Close") %>"> |
||||
<span aria-hidden="true">×</span> |
||||
</button> |
||||
</div> |
||||
<div class="modal-body"> |
||||
<img src="data:image/png;base64, <%= qr_code(@conn, @token_id, @token.contract_address_hash) %> " class="qr-code" alt="qr_code" title="<%= @token.contract_address %>" /> |
||||
</div> |
||||
<div class="modal-footer"> |
||||
<button type="button" class="btn btn-primary" data-dismiss="modal"><%= gettext "Close" %></button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
@ -0,0 +1,15 @@ |
||||
<div class="card-tabs js-card-tabs"> |
||||
<%= link( |
||||
gettext("Token Transfers"), |
||||
class: "card-tab #{tab_status("token_transfers", @conn.request_path)}", |
||||
to: token_instance_path(@conn, :show, @token.contract_address_hash, to_string(@token_instance.token_id)) |
||||
) |
||||
%> |
||||
<%= if @token_instance.instance do %> |
||||
<%= link( |
||||
gettext("Metadata"), |
||||
to: token_instance_metadata_path(@conn, :index, @token.contract_address_hash, to_string(@token_instance.token_id)), |
||||
class: "card-tab #{tab_status("metadata", @conn.request_path)}") |
||||
%> |
||||
<% end %> |
||||
</div> |
@ -0,0 +1,39 @@ |
||||
<section class="container"> |
||||
<%= render( |
||||
OverviewView, |
||||
"_details.html", |
||||
token: @token, |
||||
total_token_transfers: @total_token_transfers, |
||||
token_id: @token_instance.token_id, |
||||
token_instance: @token_instance, |
||||
conn: @conn |
||||
) %> |
||||
|
||||
<section> |
||||
<div class="card"> |
||||
<%= render OverviewView, "_tabs.html", assigns %> |
||||
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>"> |
||||
<h2 class="card-title"><%= gettext "Token Transfers" %></h2> |
||||
|
||||
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "top", cur_page_number: "1", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||
|
||||
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;"> |
||||
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span> |
||||
</button> |
||||
|
||||
<div data-empty-response-message class="tile tile-muted text-center" style="display: none;"> |
||||
<span data-selector="empty-transactions-list"> |
||||
<%= gettext "There are no transfers for this Token." %> |
||||
</span> |
||||
</div> |
||||
|
||||
<div data-items> |
||||
<%= render BlockScoutWeb.CommonComponentsView, "_tile-loader.html" %> |
||||
</div> |
||||
|
||||
<%= render BlockScoutWeb.CommonComponentsView, "_pagination_container.html", position: "bottom", cur_page_number: "1", show_pagination_limit: true, data_next_page_button: true, data_prev_page_button: true %> |
||||
|
||||
</div> |
||||
</div> |
||||
</section> |
||||
</section> |
@ -0,0 +1,9 @@ |
||||
defmodule BlockScoutWeb.Tokens.Instance.MetadataView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.Tokens.Instance.OverviewView |
||||
|
||||
def format_metadata(nil), do: "" |
||||
|
||||
def format_metadata(metadata), do: Poison.encode!(metadata, pretty: true) |
||||
end |
@ -0,0 +1,62 @@ |
||||
defmodule BlockScoutWeb.Tokens.Instance.OverviewView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.CurrencyHelpers |
||||
alias Explorer.Chain.{Address, SmartContract, Token} |
||||
|
||||
import BlockScoutWeb.APIDocsView, only: [blockscout_url: 0] |
||||
|
||||
@tabs ["token_transfers", "metadata"] |
||||
|
||||
def token_name?(%Token{name: nil}), do: false |
||||
def token_name?(%Token{name: _}), do: true |
||||
|
||||
def decimals?(%Token{decimals: nil}), do: false |
||||
def decimals?(%Token{decimals: _}), do: true |
||||
|
||||
def total_supply?(%Token{total_supply: nil}), do: false |
||||
def total_supply?(%Token{total_supply: _}), do: true |
||||
|
||||
def image_src(nil), do: "/images/controller.svg" |
||||
|
||||
def image_src(instance) do |
||||
cond do |
||||
instance.metadata && instance.metadata["image_url"] -> instance.metadata["image_url"] |
||||
instance.metadata && instance.metadata["image"] -> instance.metadata["image"] |
||||
true -> image_src(nil) |
||||
end |
||||
end |
||||
|
||||
def total_supply_usd(token) do |
||||
tokens = CurrencyHelpers.divide_decimals(token.total_supply, token.decimals) |
||||
price = token.usd_value |
||||
Decimal.mult(tokens, price) |
||||
end |
||||
|
||||
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 |
||||
|
||||
def qr_code(conn, token_id, hash) do |
||||
token_instance_path = token_instance_path(conn, :show, to_string(hash), to_string(token_id)) |
||||
|
||||
url = Path.join(blockscout_url(), token_instance_path) |
||||
|
||||
url |
||||
|> QRCode.to_png() |
||||
|> Base.encode64() |
||||
end |
||||
|
||||
def current_tab_name(request_path) do |
||||
@tabs |
||||
|> Enum.filter(&tab_active?(&1, request_path)) |
||||
|> tab_name() |
||||
end |
||||
|
||||
defp tab_name(["token_transfers"]), do: gettext("Token Transfers") |
||||
defp tab_name(["metadata"]), do: gettext("Metadata") |
||||
end |
@ -0,0 +1,5 @@ |
||||
defmodule BlockScoutWeb.Tokens.Instance.TransferView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.Tokens.Instance.OverviewView |
||||
end |
@ -0,0 +1,3 @@ |
||||
defmodule BlockScoutWeb.Tokens.InstanceView do |
||||
use BlockScoutWeb, :view |
||||
end |
@ -0,0 +1,23 @@ |
||||
defmodule BlockScoutWeb.Tokens.InstanceControllerTest do |
||||
use BlockScoutWeb.ConnCase, async: false |
||||
|
||||
describe "GET show/2" do |
||||
test "redirects with valid params", %{conn: conn} do |
||||
contract_address = insert(:address) |
||||
|
||||
insert(:token, contract_address: contract_address) |
||||
|
||||
token_id = 10 |
||||
|
||||
insert(:token_transfer, |
||||
from_address: contract_address, |
||||
token_contract_address: contract_address, |
||||
token_id: token_id |
||||
) |
||||
|
||||
conn = get(conn, token_instance_path(BlockScoutWeb.Endpoint, :show, to_string(contract_address.hash), token_id)) |
||||
|
||||
assert conn.status == 302 |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,46 @@ |
||||
defmodule Explorer.Chain.Token.Instance do |
||||
@moduledoc """ |
||||
Represents an ERC 721 token instance and stores metadata defined in https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md. |
||||
""" |
||||
|
||||
use Explorer.Schema |
||||
|
||||
alias Explorer.Chain.{Hash, Token} |
||||
alias Explorer.Chain.Token.Instance |
||||
|
||||
@typedoc """ |
||||
* `token_id` - ID of the token |
||||
* `token_contract_address_hash` - Address hash foreign key |
||||
* `metadata` - Token instance metadata |
||||
""" |
||||
|
||||
@type t :: %Instance{ |
||||
token_id: non_neg_integer(), |
||||
token_contract_address_hash: Hash.Address.t(), |
||||
metadata: Map.t() |
||||
} |
||||
|
||||
@primary_key false |
||||
schema "token_instances" do |
||||
field(:token_id, :decimal, primary_key: true) |
||||
field(:metadata, :map) |
||||
|
||||
belongs_to( |
||||
:token, |
||||
Token, |
||||
foreign_key: :token_contract_address_hash, |
||||
references: :contract_address_hash, |
||||
type: Hash.Address, |
||||
primary_key: true |
||||
) |
||||
|
||||
timestamps() |
||||
end |
||||
|
||||
def changeset(%Instance{} = instance, params \\ %{}) do |
||||
instance |
||||
|> cast(params, [:token_id, :metadata, :token_contract_address_hash]) |
||||
|> validate_required([:token_id, :token_contract_address_hash]) |
||||
|> foreign_key_constraint(:token_contract_address_hash) |
||||
end |
||||
end |
@ -0,0 +1,69 @@ |
||||
defmodule Explorer.Token.InstanceMetadataRetriever do |
||||
@moduledoc """ |
||||
Fetches ERC721 token instance metadata. |
||||
""" |
||||
|
||||
require Logger |
||||
|
||||
alias Explorer.SmartContract.Reader |
||||
alias HTTPoison.{Error, Response} |
||||
|
||||
@abi [ |
||||
%{ |
||||
"type" => "function", |
||||
"stateMutability" => "view", |
||||
"payable" => false, |
||||
"outputs" => [ |
||||
%{"type" => "string", "name" => ""} |
||||
], |
||||
"name" => "tokenURI", |
||||
"inputs" => [ |
||||
%{ |
||||
"type" => "uint256", |
||||
"name" => "_tokenId" |
||||
} |
||||
], |
||||
"constant" => true |
||||
} |
||||
] |
||||
|
||||
@cryptokitties_address_hash "0x06012c8cf97bead5deae237070f9587f8e7a266d" |
||||
|
||||
def fetch_metadata(unquote(@cryptokitties_address_hash), token_id) do |
||||
%{"tokenURI" => {:ok, ["https://api.cryptokitties.co/kitties/#{token_id}"]}} |
||||
|> fetch_json() |
||||
end |
||||
|
||||
def fetch_metadata(contract_address_hash, token_id) do |
||||
contract_functions = %{"tokenURI" => [token_id]} |
||||
|
||||
contract_address_hash |
||||
|> query_contract(contract_functions) |
||||
|> fetch_json() |
||||
end |
||||
|
||||
def query_contract(contract_address_hash, contract_functions) do |
||||
Reader.query_contract(contract_address_hash, @abi, contract_functions) |
||||
end |
||||
|
||||
defp fetch_json(%{"tokenURI" => {:ok, [""]}}) do |
||||
{:error, :no_uri} |
||||
end |
||||
|
||||
defp fetch_json(%{"tokenURI" => {:ok, [token_uri]}}) do |
||||
case HTTPoison.get(token_uri) do |
||||
{:ok, %Response{body: body, status_code: 200}} -> |
||||
Jason.decode(body) |
||||
|
||||
{:ok, %Response{body: body}} -> |
||||
{:error, body} |
||||
|
||||
{:error, %Error{reason: reason}} -> |
||||
{:error, reason} |
||||
end |
||||
end |
||||
|
||||
defp fetch_json(result) do |
||||
{:error, result} |
||||
end |
||||
end |
@ -0,0 +1,22 @@ |
||||
defmodule Explorer.Repo.Migrations.CreateTokenInstances do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
create table(:token_instances, primary_key: false) do |
||||
# ERC-721 tokens have IDs |
||||
# 10^x = 2^256, x ~ 77.064, so 78 decimal digits will store the full 256-bits of a native EVM type |
||||
add(:token_id, :numeric, precision: 78, scale: 0, null: false, primary_key: true) |
||||
|
||||
add(:token_contract_address_hash, references(:tokens, column: :contract_address_hash, type: :bytea), |
||||
null: false, |
||||
primary_key: true |
||||
) |
||||
|
||||
add(:metadata, :jsonb) |
||||
|
||||
timestamps(null: false, type: :utc_datetime_usec) |
||||
end |
||||
|
||||
create_if_not_exists(index(:token_instances, [:token_id])) |
||||
end |
||||
end |
@ -0,0 +1,53 @@ |
||||
defmodule Explorer.Token.InstanceMetadataRetrieverTest do |
||||
use EthereumJSONRPC.Case |
||||
|
||||
alias Explorer.Token.InstanceMetadataRetriever |
||||
|
||||
import Mox |
||||
|
||||
setup :verify_on_exit! |
||||
setup :set_mox_global |
||||
|
||||
describe "fetch_metadata/2" do |
||||
@tag :no_parity |
||||
@tag :no_geth |
||||
test "fetches json metadata", %{json_rpc_named_arguments: json_rpc_named_arguments} do |
||||
if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do |
||||
EthereumJSONRPC.Mox |
||||
|> expect(:json_rpc, fn [ |
||||
%{ |
||||
id: 0, |
||||
jsonrpc: "2.0", |
||||
method: "eth_call", |
||||
params: [ |
||||
%{ |
||||
data: |
||||
"0xc87b56dd000000000000000000000000000000000000000000000000fdd5b9fa9d4bfb20", |
||||
to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" |
||||
}, |
||||
"latest" |
||||
] |
||||
} |
||||
], |
||||
_options -> |
||||
{:ok, |
||||
[ |
||||
%{ |
||||
id: 0, |
||||
jsonrpc: "2.0", |
||||
result: |
||||
"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003568747470733a2f2f7661756c742e7761727269646572732e636f6d2f31383239303732393934373636373130323439362e6a736f6e0000000000000000000000" |
||||
} |
||||
]} |
||||
end) |
||||
end |
||||
|
||||
assert %{ |
||||
"tokenURI" => {:ok, ["https://vault.warriders.com/18290729947667102496.json"]} |
||||
} == |
||||
InstanceMetadataRetriever.query_contract("0x5caebd3b32e210e85ce3e9d51638b9c445481567", %{ |
||||
"tokenURI" => [18_290_729_947_667_102_496] |
||||
}) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,75 @@ |
||||
defmodule Indexer.Fetcher.TokenInstance do |
||||
@moduledoc """ |
||||
Fetches information about a token instance. |
||||
""" |
||||
|
||||
use Indexer.Fetcher |
||||
use Spandex.Decorators |
||||
|
||||
alias Explorer.Chain |
||||
alias Explorer.Token.InstanceMetadataRetriever |
||||
alias Indexer.BufferedTask |
||||
|
||||
@behaviour BufferedTask |
||||
|
||||
@defaults [ |
||||
flush_interval: 300, |
||||
max_batch_size: 1, |
||||
max_concurrency: 10, |
||||
task_supervisor: Indexer.Fetcher.TokenInstance.TaskSupervisor |
||||
] |
||||
|
||||
@doc false |
||||
def child_spec([init_options, gen_server_options]) do |
||||
{state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) |
||||
|
||||
unless state do |
||||
raise ArgumentError, |
||||
":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> |
||||
"to allow for json_rpc calls when running." |
||||
end |
||||
|
||||
merged_init_opts = |
||||
@defaults |
||||
|> Keyword.merge(mergeable_init_options) |
||||
|> Keyword.put(:state, state) |
||||
|
||||
Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) |
||||
end |
||||
|
||||
@impl BufferedTask |
||||
def init(initial_acc, reducer, _) do |
||||
{:ok, acc} = |
||||
Chain.stream_unfetched_token_instances(initial_acc, fn data, acc -> |
||||
reducer.(data, acc) |
||||
end) |
||||
|
||||
acc |
||||
end |
||||
|
||||
@impl BufferedTask |
||||
def run([%{contract_address_hash: token_contract_address_hash, token_id: token_id}], _json_rpc_named_arguments) do |
||||
case InstanceMetadataRetriever.fetch_metadata(to_string(token_contract_address_hash), Decimal.to_integer(token_id)) do |
||||
{:ok, metadata} -> |
||||
params = %{ |
||||
token_id: token_id, |
||||
token_contract_address_hash: token_contract_address_hash, |
||||
metadata: metadata |
||||
} |
||||
|
||||
{:ok, _result} = Chain.upsert_token_instance(params) |
||||
|
||||
_other -> |
||||
:ok |
||||
end |
||||
|
||||
:ok |
||||
end |
||||
|
||||
@doc """ |
||||
Fetches token instance data asynchronously. |
||||
""" |
||||
def async_fetch(data) do |
||||
BufferedTask.buffer(__MODULE__, data) |
||||
end |
||||
end |
Loading…
Reference in new issue