parent
04d734fb1f
commit
616658adbf
@ -0,0 +1,131 @@ |
||||
defmodule Explorer.KnownTokens do |
||||
@moduledoc """ |
||||
Local cache for known tokens addresses. This fetches and exposes a mapping from a token symbol to the known contract |
||||
address for the token with that symbol. This data can be consumed through the Market module. |
||||
|
||||
Data is updated every 1 hour. |
||||
""" |
||||
|
||||
use GenServer |
||||
|
||||
require Logger |
||||
|
||||
alias Explorer.Chain.Hash |
||||
alias Explorer.KnownTokens.Source |
||||
|
||||
@interval :timer.hours(1) |
||||
@table_name :known_tokens |
||||
|
||||
@impl GenServer |
||||
def handle_info(:update, state) do |
||||
Logger.debug(fn -> "Updating cached known tokens" end) |
||||
|
||||
fetch_known_tokens() |
||||
|
||||
{:noreply, state} |
||||
end |
||||
|
||||
# Callback for successful fetch |
||||
@impl GenServer |
||||
def handle_info({_ref, {:ok, addresses}}, state) do |
||||
if store() == :ets do |
||||
records = Enum.map(addresses, fn x -> {x["symbol"], x["address"]} end) |
||||
|
||||
:ets.insert(table_name(), records) |
||||
end |
||||
|
||||
{:noreply, state} |
||||
end |
||||
|
||||
# Callback for errored fetch |
||||
@impl GenServer |
||||
def handle_info({_ref, {:error, reason}}, state) do |
||||
Logger.warn(fn -> "Failed to get known tokens with reason '#{reason}'." end) |
||||
|
||||
fetch_known_tokens() |
||||
|
||||
{:noreply, state} |
||||
end |
||||
|
||||
# Callback that a monitored process has shutdown |
||||
@impl GenServer |
||||
def handle_info({:DOWN, _, :process, _, _}, state) do |
||||
{:noreply, state} |
||||
end |
||||
|
||||
@impl GenServer |
||||
def init(_) do |
||||
send(self(), :update) |
||||
:timer.send_interval(@interval, :update) |
||||
|
||||
table_opts = [ |
||||
:set, |
||||
:named_table, |
||||
:public, |
||||
read_concurrency: true |
||||
] |
||||
|
||||
if store() == :ets do |
||||
:ets.new(table_name(), table_opts) |
||||
end |
||||
|
||||
{:ok, %{}} |
||||
end |
||||
|
||||
def start_link(opts) do |
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__) |
||||
end |
||||
|
||||
@doc """ |
||||
Lists known tokens. |
||||
""" |
||||
@spec list :: [{String.t(), Hash.Address.t()}] |
||||
def list do |
||||
list_from_store(store()) |
||||
end |
||||
|
||||
@doc """ |
||||
Returns a specific address from the known tokens by symbol |
||||
""" |
||||
@spec lookup(String.t()) :: {:ok, Hash.Address.t()} | :error | nil |
||||
def lookup(symbol) do |
||||
if store() == :ets do |
||||
case :ets.lookup(table_name(), symbol) do |
||||
[{_symbol, address} | _] -> Hash.Address.cast(address) |
||||
_ -> nil |
||||
end |
||||
end |
||||
end |
||||
|
||||
@doc false |
||||
@spec table_name() :: atom() |
||||
def table_name do |
||||
config(:table_name) || @table_name |
||||
end |
||||
|
||||
@spec config(atom()) :: term |
||||
defp config(key) do |
||||
Application.get_env(:explorer, __MODULE__, [])[key] |
||||
end |
||||
|
||||
@spec fetch_known_tokens :: Task.t() |
||||
defp fetch_known_tokens do |
||||
Task.Supervisor.async_nolink(Explorer.MarketTaskSupervisor, fn -> |
||||
Source.fetch_known_tokens() |
||||
end) |
||||
end |
||||
|
||||
defp list_from_store(:ets) do |
||||
table_name() |
||||
|> :ets.tab2list() |
||||
|> Enum.map(&elem(&1, 1)) |
||||
|> Enum.map(&Hash.Address.cast/1) |
||||
|> Enum.sort() |
||||
end |
||||
|
||||
defp list_from_store(_), do: [] |
||||
|
||||
defp store do |
||||
config(:store) || :ets |
||||
end |
||||
end |
@ -0,0 +1,48 @@ |
||||
defmodule Explorer.KnownTokens.Source do |
||||
@moduledoc """ |
||||
Behaviour for fetching list of known tokens. |
||||
""" |
||||
|
||||
alias Explorer.Chain.Hash |
||||
alias HTTPoison.{Error, Response} |
||||
|
||||
@doc """ |
||||
Fetches known tokens |
||||
""" |
||||
@spec fetch_known_tokens() :: {:ok, [Hash.Address.t()]} | {:error, any} |
||||
def fetch_known_tokens(source \\ known_tokens_source()) do |
||||
case HTTPoison.get(source.source_url(), headers()) do |
||||
{:ok, %Response{body: body, status_code: 200}} -> |
||||
{:ok, decode_json(body)} |
||||
|
||||
{:ok, %Response{body: body, status_code: status_code}} when status_code in 400..499 -> |
||||
{:error, decode_json(body)["error"]} |
||||
|
||||
{:error, %Error{reason: reason}} -> |
||||
{:error, reason} |
||||
end |
||||
end |
||||
|
||||
@doc """ |
||||
Url for querying the list of known tokens. |
||||
""" |
||||
@callback source_url() :: String.t() |
||||
|
||||
def headers do |
||||
[{"Content-Type", "application/json"}] |
||||
end |
||||
|
||||
def decode_json(data) do |
||||
Jason.decode!(data) |
||||
end |
||||
|
||||
@spec known_tokens_source() :: module() |
||||
defp known_tokens_source do |
||||
config(:source) || Explorer.KnownTokens.Source.MyEtherWallet |
||||
end |
||||
|
||||
@spec config(atom()) :: term |
||||
defp config(key) do |
||||
Application.get_env(:explorer, __MODULE__, [])[key] |
||||
end |
||||
end |
@ -0,0 +1,14 @@ |
||||
defmodule Explorer.KnownTokens.Source.MyEtherWallet do |
||||
@moduledoc """ |
||||
Adapter for fetching known tokens from MyEtherWallet's GitHub |
||||
""" |
||||
|
||||
alias Explorer.KnownTokens.Source |
||||
|
||||
@behaviour Source |
||||
|
||||
@impl Source |
||||
def source_url do |
||||
"https://raw.githubusercontent.com/kvhnuke/etherwallet/mercury/app/scripts/tokens/ethTokens.json" |
||||
end |
||||
end |
@ -0,0 +1,131 @@ |
||||
defmodule Explorer.KnownTokensTest do |
||||
use ExUnit.Case, async: false |
||||
|
||||
import Mox |
||||
|
||||
alias Plug.Conn |
||||
alias Explorer.Chain.Hash |
||||
alias Explorer.KnownTokens |
||||
alias Explorer.KnownTokens.Source.TestSource |
||||
|
||||
@moduletag :capture_log |
||||
|
||||
setup :verify_on_exit! |
||||
|
||||
setup do |
||||
set_mox_global() |
||||
|
||||
# Use TestSource mock and ets table for this test set |
||||
source_configuration = Application.get_env(:explorer, Explorer.KnownTokens.Source) |
||||
known_tokens_configuration = Application.get_env(:explorer, Explorer.KnownTokens) |
||||
|
||||
Application.put_env(:explorer, Explorer.KnownTokens.Source, source: TestSource) |
||||
Application.put_env(:explorer, Explorer.KnownTokens, table_name: :known_tokens) |
||||
|
||||
on_exit(fn -> |
||||
Application.put_env(:explorer, Explorer.KnownTokens.Source, source_configuration) |
||||
Application.put_env(:explorer, Explorer.KnownTokens, known_tokens_configuration) |
||||
end) |
||||
end |
||||
|
||||
test "init" do |
||||
assert :ets.info(KnownTokens.table_name()) == :undefined |
||||
|
||||
assert {:ok, %{}} == KnownTokens.init([]) |
||||
assert_received :update |
||||
table = :ets.info(KnownTokens.table_name()) |
||||
refute table == :undefined |
||||
assert table[:name] == KnownTokens.table_name() |
||||
assert table[:named_table] |
||||
assert table[:read_concurrency] |
||||
assert table[:type] == :set |
||||
refute table[:write_concurrency] |
||||
end |
||||
|
||||
test "handle_info with :update" do |
||||
bypass = Bypass.open() |
||||
|
||||
Bypass.expect(bypass, "GET", "/", fn conn -> |
||||
Conn.resp(conn, 200, ~s([{"symbol": "TEST1","address": "0x0000000000000000000000000000000000000001"}])) |
||||
end) |
||||
|
||||
stub(TestSource, :source_url, fn -> "http://localhost:#{bypass.port}" end) |
||||
|
||||
KnownTokens.init([]) |
||||
state = %{} |
||||
|
||||
assert {:noreply, ^state} = KnownTokens.handle_info(:update, state) |
||||
assert_receive {_, {:ok, [%{"symbol" => "TEST1", "address" => "0x0000000000000000000000000000000000000001"}]}} |
||||
end |
||||
|
||||
describe "ticker fetch task" do |
||||
setup do |
||||
KnownTokens.init([]) |
||||
:ok |
||||
end |
||||
|
||||
test "with successful fetch" do |
||||
symbol = "TEST2" |
||||
address = "0x0000000000000000000000000000000000000002" |
||||
|
||||
token = %{ |
||||
"symbol" => symbol, |
||||
"address" => address |
||||
} |
||||
|
||||
state = %{} |
||||
|
||||
assert {:noreply, ^state} = KnownTokens.handle_info({nil, {:ok, [token]}}, state) |
||||
|
||||
assert [{"TEST2", "0x0000000000000000000000000000000000000002"}] == :ets.lookup(KnownTokens.table_name(), symbol) |
||||
end |
||||
|
||||
test "with failed fetch" do |
||||
bypass = Bypass.open() |
||||
|
||||
Bypass.expect(bypass, "GET", "/", fn conn -> |
||||
Conn.resp(conn, 200, "{}") |
||||
end) |
||||
|
||||
stub(TestSource, :source_url, fn -> "http://localhost:#{bypass.port}" end) |
||||
|
||||
state = %{} |
||||
|
||||
assert {:noreply, ^state} = KnownTokens.handle_info({nil, {:error, "some error"}}, state) |
||||
|
||||
assert_receive {_, {:ok, _}} |
||||
end |
||||
end |
||||
|
||||
test "list/0" do |
||||
KnownTokens.init([]) |
||||
|
||||
known_tokens = [ |
||||
{"TEST1", "0x0000000000000000000000000000000000000001"}, |
||||
{"TEST2", "0x0000000000000000000000000000000000000002"} |
||||
] |
||||
|
||||
:ets.insert(KnownTokens.table_name(), known_tokens) |
||||
|
||||
expected_tokens = |
||||
known_tokens |
||||
|> Enum.map(&elem(&1, 1)) |
||||
|> Enum.map(&Hash.Address.cast/1) |
||||
|
||||
assert expected_tokens == KnownTokens.list() |
||||
end |
||||
|
||||
test "lookup/1" do |
||||
KnownTokens.init([]) |
||||
|
||||
known_tokens = [ |
||||
{"TEST1", "0x0000000000000000000000000000000000000001"}, |
||||
{"TEST2", "0x0000000000000000000000000000000000000002"} |
||||
] |
||||
|
||||
:ets.insert(KnownTokens.table_name(), known_tokens) |
||||
|
||||
assert Hash.Address.cast("0x0000000000000000000000000000000000000001") == KnownTokens.lookup("TEST1") |
||||
assert nil == KnownTokens.lookup("nope") |
||||
end |
||||
end |
Loading…
Reference in new issue