diff --git a/config/config.exs b/config/config.exs index 684b54a..43a182b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -47,6 +47,8 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :noncegeek, AptosEx, rpc_endpoint: "https://testnet.aptoslabs.com/v1" + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/noncegeek/application.ex b/lib/noncegeek/application.ex index 2b8b473..a099b70 100644 --- a/lib/noncegeek/application.ex +++ b/lib/noncegeek/application.ex @@ -15,7 +15,8 @@ defmodule Noncegeek.Application do # Start the PubSub system {Phoenix.PubSub, name: Noncegeek.PubSub}, # Start the Endpoint (http/https) - NoncegeekWeb.Endpoint + NoncegeekWeb.Endpoint, + {AptosEx, Application.fetch_env!(:noncegeek, AptosEx)} # Start a worker by calling: Noncegeek.Worker.start_link(arg) # {Noncegeek.Worker, arg} ] diff --git a/lib/noncegeek/aptos_ex/aptos_ex.ex b/lib/noncegeek/aptos_ex/aptos_ex.ex new file mode 100644 index 0000000..8991500 --- /dev/null +++ b/lib/noncegeek/aptos_ex/aptos_ex.ex @@ -0,0 +1,150 @@ +defmodule AptosEx do + @moduledoc """ + The AptosEx module is a wrapper around the Aptos API. + """ + + use GenServer + + alias AptosEx.RPC + + defmodule State do + defstruct ~w( + rpc_endpoint + client + )a + end + + def start_link(options) do + rpc_endpoint = Keyword.get(options, :rpc_endpoint) + + unless rpc_endpoint do + raise ArgumentError, + ":rpc_endpoint must be provided to `#{__MODULE__}`" <> + "to allow for json_rpc calls when running" + end + + GenServer.start_link(__MODULE__, rpc_endpoint, name: __MODULE__) + end + + @doc """ + Get Aptos client + """ + def get_aptos_client() do + with {:ok, client} <- GenServer.call(__MODULE__, :client) do + client + else + _ -> + raise RuntimeError, "connect aptos rpc failed, please check your config" + end + end + + @impl GenServer + def init(rpc_endpoint) do + state = %State{ + rpc_endpoint: rpc_endpoint + } + + {:ok, state, {:continue, :start}} + end + + @impl GenServer + def handle_continue(:start, %State{rpc_endpoint: rpc_endpoint} = state) do + client = RPC.connect(rpc_endpoint) + {:noreply, %{state | client: client}} + end + + @impl GenServer + def handle_call(:client, _, %{client: client} = state) do + {:reply, client, state} + end + + @doc """ + Get Aptos account + """ + def get_account(address) do + client = get_aptos_client() + RPC.get_account(client, address) + end + + @doc """ + Get account resources + """ + def get_account_resources(address, query \\ []) do + client = get_aptos_client() + RPC.get_account_resources(client, address, query) + end + + @doc """ + Get account resource + """ + def get_account_resource(address, resource_type, query \\ []) do + client = get_aptos_client() + RPC.get_account_resource(client, address, resource_type, query) + end + + @doc """ + Get transaction by hash. + """ + def get_transaction_by_hash(hash) do + client = get_aptos_client() + RPC.get_transaction_by_hash(client, hash) + end + + @doc """ + Check transaction result. + """ + def check_transaction_by_hash(hash, times \\ 3) do + client = get_aptos_client() + RPC.check_transaction_by_hash(client, hash, times) + end + + @doc """ + Get events by event_key + """ + def get_events(event_key) do + client = get_aptos_client() + RPC.get_events(client, event_key) + end + + @doc """ + Get events by address event_handle + """ + def get_events(address, event_handle, field, query \\ [limit: 10]) do + client = get_aptos_client() + RPC.get_events(client, address, event_handle, field, query) + end + + @doc """ + Get table item + """ + def get_table_item(table_handle, table_key) do + client = get_aptos_client() + RPC.get_table_item(client, table_handle, table_key) + end + + @doc """ + Get token detail + + ## Examples + + iex> AptosEx.get_token_data("0xe19430a2498ff6800666d41cfd4b64d6d2a53574ef7457f700f96f4a61703d07", "DummyNames", "dummy1") + + """ + def get_token_data(creator, collection_name, token_name) do + client = get_aptos_client() + RPC.get_token_data(client, creator, collection_name, token_name) + end + + @doc """ + Get collection data + + ## Examples + + iex> AptosEx.get_collection_data("0xe19430a2498ff6800666d41cfd4b64d6d2a53574ef7457f700f96f4a61703d07", "DummyNames") + + """ + def get_collection_data(account, collection_name) do + client = get_aptos_client() + RPC.get_collection_data(client, account, collection_name) + end +end diff --git a/lib/noncegeek/aptos_ex/rpc.ex b/lib/noncegeek/aptos_ex/rpc.ex new file mode 100644 index 0000000..a5f8bec --- /dev/null +++ b/lib/noncegeek/aptos_ex/rpc.ex @@ -0,0 +1,144 @@ +defmodule AptosEx.RPC do + @moduledoc false + + defstruct [:endpoint, :client, :chain_id] + + # @endpoint "https://fullnode.devnet.aptoslabs.com/v1" + @endpoint "https://testnet.aptoslabs.com/v1" + + def connect(endpoint \\ @endpoint) do + client = + Tesla.client([ + # TODO: convert input/output type + {Tesla.Middleware.BaseUrl, endpoint}, + # {Tesla.Middleware.Headers, [{"content-type", "application/json"}]}, + {Tesla.Middleware.JSON, engine_opts: [keys: :atoms]} + ]) + + rpc = %__MODULE__{client: client, endpoint: endpoint} + + with {:ok, %{chain_id: chain_id}} <- ledger_information(rpc) do + {:ok, %{rpc | endpoint: endpoint, chain_id: chain_id}} + end + end + + defp get(%{client: client}, path, options \\ []) do + with {:ok, %{status: 200, body: resp_body}} <- Tesla.get(client, path, options) do + {:ok, resp_body} + else + {:ok, %{body: resp_body}} -> {:error, resp_body} + {:error, error} -> {:error, error} + end + end + + defp post(%{client: client}, path, body, options \\ []) do + with {:ok, %{body: resp_body}} <- Tesla.post(client, path, body, options) do + case resp_body do + %{code: _, message: message} -> {:error, message} + _ -> {:ok, resp_body} + end + else + {:error, error} -> {:error, error} + end + end + + # Chain + def ledger_information(client) do + get(client, "/") + end + + # Accounts + def get_account(client, address) do + get(client, "/accounts/#{address}") + end + + def get_account_resources(client, address, query \\ []) do + get(client, "/accounts/#{address}/resources", query: query) + end + + def get_account_resource(client, address, resource_type, query \\ []) do + get(client, "/accounts/#{address}/resource/#{resource_type}", query: query) + end + + # Transactions + def get_transaction_by_hash(client, hash) do + get(client, "/transactions/by_hash/#{hash}") + end + + def check_transaction_by_hash(client, hash, times \\ 3) do + case get_transaction_by_hash(client, hash) do + {:ok, result} -> + result.success + + {:error, _} -> + if times > 0 do + Process.sleep(1000) + check_transaction_by_hash(client, hash, times - 1) + else + false + end + end + end + + # Events + def get_events(client, event_key) do + case get(client, "/events/#{event_key}") do + {:ok, event_list} -> {:ok, event_list} + {:error, %{error_code: "resource_not_found"}} -> {:ok, []} + end + end + + def get_events(client, address, event_handle, field, query \\ [limit: 10]) do + case get(client, "/accounts/#{address}/events/#{event_handle}/#{field}", query: query) do + {:ok, event_list} -> {:ok, event_list} + {:error, %{error_code: "resource_not_found"}} -> {:ok, []} + end + end + + # Table + def get_table_item(client, table_handle, table_key) do + post(client, "/tables/#{table_handle}/item", table_key) + end + + # Tokens + def get_token_data(client, creator, collection_name, token_name) do + with {:ok, result} <- get_account_resource(client, creator, "0x3::token::Collections") do + %{handle: handle} = result.data.token_data + + token_data_id = %{ + creator: creator, + collection: collection_name, + name: token_name + } + + table_key = %{ + key_type: "0x3::token::TokenDataId", + value_type: "0x3::token::TokenData", + key: token_data_id + } + + get_table_item(client, handle, table_key) + end + end + + def get_collection_data(client, account, collection_name) do + with {:ok, result} <- get_account_resource(client, account, "0x3::token::Collections") do + %{handle: handle} = result.data.collection_data + + table_key = %{ + key_type: "0x1::string::String", + value_type: "0x3::token::CollectionData", + key: collection_name + } + + {:ok, result} = get_table_item(client, handle, table_key) + + case result do + %{error_code: _} -> {:error, result} + _ -> {:ok, result} + end + else + _ -> {:error, "Token data not found"} + end + end +end diff --git a/lib/noncegeek/explorer/models/event.ex b/lib/noncegeek/explorer/models/event.ex new file mode 100644 index 0000000..12e175a --- /dev/null +++ b/lib/noncegeek/explorer/models/event.ex @@ -0,0 +1,47 @@ +defmodule Noncegeek.Explorer.Model.Event do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @timestamps_opts [type: :utc_datetime_usec] + schema "events" do + field :sequence_number, :integer + field :type, :string + + field :account_address, :string + + field :creation_number, :integer + field :version, :integer + + # field :token_id, :map + # field :collection_id, :map + field :amount, :decimal + field :data, :map + + belongs_to :token, Lotus.Explorer.Model.Token, + foreign_key: :token_id, + references: :token_id, + type: :map + + timestamps() + end + + @doc false + def changeset(event, attrs) do + required_fields = ~w( + account_address + sequence_number + token_id + type + data + version + )a + + optional_fields = ~w(amount)a + + event + |> cast(attrs, required_fields ++ optional_fields) + |> validate_required(required_fields) + end +end diff --git a/lib/noncegeek/explorer/models/token.ex b/lib/noncegeek/explorer/models/token.ex new file mode 100644 index 0000000..3a34373 --- /dev/null +++ b/lib/noncegeek/explorer/models/token.ex @@ -0,0 +1,75 @@ +defmodule Noncegeek.Explorer.Model.Token do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + @timestamps_opts [type: :utc_datetime_usec] + schema "tokens" do + # required + field :token_id, :map + field :collection_id, :map + field :collection_name, :string + field :creator, :string + field :name, :string + + # optional + field :default_properties, :map + field :description, :string + field :largest_property_version, :decimal + field :maximum, :decimal + field :supply, :decimal + field :mutability_config, :map + field :royalty, :map + field :uri, :string + field :property_version, :integer + + # fetcher + field :last_fetched_at, :utc_datetime_usec + + timestamps() + end + + @doc false + def changeset(token, attrs) do + required_fields = ~w( + name + creator + collection_name + property_version + token_id + )a + + optional_fields = ~w( + last_fetched_at + maximum + largest_property_version + mutability_config + description + uri + royalty + supply + default_properties + )a + + token + |> cast(attrs, required_fields ++ optional_fields) + |> validate_required(required_fields) + end + + def input_changeset(token, attrs) do + required_fields = ~w( + collection_name + name + description + uri + )a + + token + |> cast(attrs, required_fields) + |> validate_required(required_fields) + |> NameSlug.maybe_generate_slug() + |> NameSlug.unique_constraint() + end +end diff --git a/mix.exs b/mix.exs index 2de7203..15d95f8 100644 --- a/mix.exs +++ b/mix.exs @@ -7,7 +7,7 @@ defmodule Noncegeek.MixProject do version: "0.1.0", elixir: "~> 1.12", elixirc_paths: elixirc_paths(Mix.env()), - compilers: [:gettext] ++ Mix.compilers(), + compilers: Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() diff --git a/priv/repo/migrations/20221107130900_create_events.exs b/priv/repo/migrations/20221107130900_create_events.exs new file mode 100644 index 0000000..d2faead --- /dev/null +++ b/priv/repo/migrations/20221107130900_create_events.exs @@ -0,0 +1,25 @@ +defmodule Noncegeek.Repo.Migrations.CreateEvents do + use Ecto.Migration + + def change do + create table(:events) do + add :sequence_number, :integer + add :type, :string + + add :version, :integer + add :creation_number, :integer + add :account_address, :string + + add :token_id, :map + add :amount, :decimal + add :data, :map + + timestamps() + end + + create unique_index(:events, [:sequence_number, :type]) + + create index(:events, [:token_id]) + create index(:events, [:account_address]) + end +end diff --git a/priv/repo/migrations/20221107131133_create_tokens.exs b/priv/repo/migrations/20221107131133_create_tokens.exs new file mode 100644 index 0000000..85168e2 --- /dev/null +++ b/priv/repo/migrations/20221107131133_create_tokens.exs @@ -0,0 +1,29 @@ +defmodule Noncegeek.Repo.Migrations.CreateTokens do + use Ecto.Migration + + def change do + create table(:tokens) do + add :collection_name, :string + add :creator, :string + add :name, :string + + add :property_version, :integer + + add :description, :text + add :uri, :string + + add :default_properties, :map + add :largest_property_version, :decimal + add :mutability_config, :map + add :royalty, :map + add :maximum, :decimal + add :supply, :decimal + + add :token_id, :map + timestamps() + end + + create unique_index(:tokens, [:token_id]) + create unique_index(:tokens, [:name, :collection_name, :creator]) + end +end