feat: Add DIA market source (#12678)

This commit is contained in:
Maxim Filonov
2025-11-28 16:30:50 +03:00
committed by GitHub
parent e04378332f
commit 74af4f63e3
36 changed files with 60299 additions and 1876 deletions
+1 -4
View File
@@ -9,8 +9,5 @@
{"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 1},
{"lib/explorer/smart_contract/stylus/publisher_worker.ex", :exact_eq, 14},
{"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 14},
~r/lib\/phoenix\/router.ex/,
{"lib/explorer/chain/search.ex", :pattern_match, 100},
{"lib/explorer/chain/search.ex", :pattern_match, 282},
{"lib/explorer/chain/search.ex", :pattern_match, 379}
~r/lib\/phoenix\/router.ex/
]
@@ -65,6 +65,13 @@ defmodule BlockScoutWeb.ExchangeRateChannelTest do
end
test "subscribed user is notified with market history", %{token: token} do
initial_value = :persistent_term.get(:market_history_fetcher_enabled, false)
:persistent_term.put(:market_history_fetcher_enabled, true)
on_exit(fn ->
:persistent_term.put(:market_history_fetcher_enabled, initial_value)
end)
Coin.handle_info({nil, {{:ok, token}, false}}, %{})
Supervisor.terminate_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()})
Supervisor.restart_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()})
@@ -5,7 +5,7 @@ defmodule BlockScoutWeb.V2.ExchangeRateChannelTest do
alias BlockScoutWeb.Notifier
alias Explorer.Market.Fetcher.{Coin, History}
alias Explorer.Market.{MarketHistory, Token}
alias Explorer.Market.{MarketHistory, MarketHistoryCache, Token}
alias Explorer.Market.Source.OneCoinSource
alias Explorer.Market
@@ -64,7 +64,31 @@ defmodule BlockScoutWeb.V2.ExchangeRateChannelTest do
end
test "subscribed user is notified with market history", %{token: token} do
Coin.handle_info({nil, {{:ok, token}, false}}, %{})
initial_market_history_fetcher_enabled_value = :persistent_term.get(:market_history_fetcher_enabled, false)
:persistent_term.put(:market_history_fetcher_enabled, true)
Supervisor.terminate_child(Explorer.Supervisor, {ConCache, MarketHistoryCache.cache_name()})
Supervisor.restart_child(Explorer.Supervisor, {ConCache, MarketHistoryCache.cache_name()})
source_configuration = Application.get_env(:explorer, Explorer.Market.Source)
fetcher_configuration = Application.get_env(:explorer, Coin)
Application.put_env(:explorer, Explorer.Market.Source,
native_coin_source: OneCoinSource,
secondary_coin_source: OneCoinSource
)
Application.put_env(:explorer, Coin, Keyword.merge(fetcher_configuration, table_name: :rates, enabled: true))
on_exit(fn ->
Application.put_env(:explorer, Explorer.Market.Source, source_configuration)
Application.put_env(:explorer, Coin, fetcher_configuration)
Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id())
Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id())
:persistent_term.put(:market_history_fetcher_enabled, initial_market_history_fetcher_enabled_value)
end)
{:ok, state} = Coin.init([])
Coin.handle_info({nil, {{:ok, token}, false}}, state)
Supervisor.terminate_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()})
Supervisor.restart_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()})
@@ -21,6 +21,15 @@ defmodule BlockScoutWeb.Account.Api.V2.UserControllerTest do
end
describe "Test account/api/account/v2/user" do
setup do
initial_value = :persistent_term.get(:market_token_fetcher_enabled, false)
:persistent_term.put(:market_token_fetcher_enabled, true)
on_exit(fn ->
:persistent_term.put(:market_token_fetcher_enabled, initial_value)
end)
end
test "get user info", %{conn: conn, user: user} do
result_conn =
conn
@@ -3228,6 +3228,13 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do
end
test "get tokens", %{conn: conn} do
initial_value = :persistent_term.get(:market_token_fetcher_enabled, false)
:persistent_term.put(:market_token_fetcher_enabled, true)
on_exit(fn ->
:persistent_term.put(:market_token_fetcher_enabled, initial_value)
end)
address = insert(:address)
ctbs_erc_20 =
@@ -7,6 +7,15 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do
alias Plug.Conn.Query
describe "/search" do
setup do
initial_value = :persistent_term.get(:market_token_fetcher_enabled, false)
:persistent_term.put(:market_token_fetcher_enabled, true)
on_exit(fn ->
:persistent_term.put(:market_token_fetcher_enabled, initial_value)
end)
end
test "get token-transfers with ok reputation", %{conn: conn} do
init_value = Application.get_env(:block_scout_web, :hide_scam_addresses)
Application.put_env(:block_scout_web, :hide_scam_addresses, true)
@@ -242,7 +242,7 @@ defmodule BlockScoutWeb.API.V2.StatsControllerTest do
addresses = insert_transactions_in_last_seconds(55, 10800)
addresses =
Enum.map(addresses, fn addr -> to_string(addr.hash) end) |> dbg(limit: :infinity, printable_limit: :infinity)
Enum.map(addresses, fn addr -> to_string(addr.hash) end)
request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "3h"})
assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200)
@@ -250,7 +250,7 @@ defmodule BlockScoutWeb.API.V2.StatsControllerTest do
assert is_map(next_page_params)
request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "3h"}))
assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) |> dbg()
assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200)
assert length(items) == 5
end
@@ -469,6 +469,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do
end
describe "/tokens" do
setup do
initial_value = :persistent_term.get(:market_token_fetcher_enabled, false)
:persistent_term.put(:market_token_fetcher_enabled, true)
on_exit(fn ->
:persistent_term.put(:market_token_fetcher_enabled, initial_value)
end)
end
defp check_tokens_pagination(tokens, conn, additional_params \\ %{}) do
request = get(conn, "/api/v2/tokens", additional_params)
assert response = json_response(request, 200)
@@ -0,0 +1,64 @@
defmodule Explorer.Chain.FiatValueBenchmark do
@moduledoc """
Benchmark for the performance of fetching fiat value type data from the database.
"""
use Explorer.BenchmarkCase
alias Explorer.Repo
alias Explorer.Chain.Token
alias Explorer.Market.Fetcher.Token, as: TokenFetcher
def list_tokens do
Benchee.run(%{"Fiat value type performance" => fn _ -> Repo.all(Token) end},
inputs:
for with_market_data <- [false, true],
enabled_token_fetcher? when with_market_data or not enabled_token_fetcher? <- [false, true],
token_count <- [50, 100, 10000],
into: %{} do
{"#{token_count} tokens#{if with_market_data, do: " with market data", else: ""}#{if enabled_token_fetcher?, do: " with enabled token fetcher", else: ""}",
%{
token_count: token_count,
with_market_data: with_market_data,
enabled_token_fetcher: enabled_token_fetcher?
}}
end,
before_scenario: fn %{
token_count: token_count,
with_market_data: with_market_data,
enabled_token_fetcher: enabled_token_fetcher?
} = input ->
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo, ownership_timeout: :infinity)
Repo.delete_all(Token)
market_data = fn i ->
if with_market_data do
[fiat_value: Decimal.new(i), circulating_market_cap: Decimal.new(i)]
else
[fiat_value: nil, circulating_market_cap: nil]
end
end
1..token_count
|> Enum.each(fn i ->
insert(:token, market_data.(i))
end)
if enabled_token_fetcher? do
Application.put_env(:explorer, Explorer.Market.Source, tokens_source: Explorer.Market.Source.OneCoinSource)
TokenFetcher.start_link([])
end
end,
load: @path,
save: [
path: @path,
tag: "fiat-value-no-check"
],
time: 5,
formatters: [Benchee.Formatters.Console]
)
end
end
Explorer.Chain.FiatValueBenchmark.list_tokens()
+18
View File
@@ -19,4 +19,22 @@ defmodule Explorer do
def coin_name do
Application.get_env(:explorer, :coin_name)
end
@doc """
Retrieves the current operational mode of the Explorer application.
The mode determines which components of the Explorer application are active:
- `:all` - Both API web server and blockchain indexer run together
- `:indexer` - Only blockchain indexer modules run without the web server
- `:api` - Only the web server runs without performing indexing operations
## Returns
- `:all` if both API and indexer components should be active
- `:indexer` if only the indexer component should be active
- `:api` if only the API web server component should be active
"""
@spec mode() :: :all | :indexer | :api
def mode do
Application.get_env(:explorer, :mode)
end
end
+6 -4
View File
@@ -116,10 +116,10 @@ defmodule Explorer.Application do
defp configurable_children do
configurable_children_set =
[
configure_libcluster(),
configure_mode_dependent_process(Explorer.Market.Fetcher.Coin, :api),
configure_mode_dependent_process(Explorer.Market.Fetcher.Token, :indexer),
configure_mode_dependent_process(Explorer.Market.Fetcher.History, :indexer),
configure_mode_dependent_process(Explorer.Market, :api),
configure(Explorer.ChainSpec.GenesisData),
configure(Explorer.Chain.Cache.Counters.ContractsCount),
configure(Explorer.Chain.Cache.Counters.NewContractsCount),
@@ -328,7 +328,9 @@ defmodule Explorer.Application do
configure(Explorer.Chain.Fetcher.AddressesBlacklist),
Explorer.Migrator.SwitchPendingOperations,
configure_mode_dependent_process(Explorer.Utility.RateLimiter, :api),
Hammer.child_for_supervisor() |> configure_mode_dependent_process(:api)
Hammer.child_for_supervisor() |> configure_mode_dependent_process(:api),
# keep at the end
configure_libcluster()
]
|> List.flatten()
@@ -431,7 +433,7 @@ defmodule Explorer.Application do
end
defp configure_mode_dependent_process(process, mode) do
if should_start?(process) and Application.get_env(:explorer, :mode) in [mode, :all] do
if should_start?(process) and Explorer.mode() in [mode, :all] do
process
else
[]
@@ -499,7 +501,7 @@ defmodule Explorer.Application do
end
defp configure_libcluster do
if Application.get_env(:explorer, :mode) in [:indexer, :api] do
if Explorer.mode() in [:indexer, :api] do
libcluster()
else
[]
@@ -267,20 +267,22 @@ defmodule Explorer.Chain.Import.Runner.Tokens do
fragment("COALESCE(EXCLUDED.circulating_market_cap, ?)", token.circulating_market_cap),
volume_24h: fragment("COALESCE(EXCLUDED.volume_24h, ?)", token.volume_24h),
icon_url: fragment("COALESCE(?, EXCLUDED.icon_url)", token.icon_url),
decimals: fragment("COALESCE(?, EXCLUDED.decimals)", token.decimals),
inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token.inserted_at),
updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token.updated_at)
]
],
where:
fragment(
"(EXCLUDED.name, EXCLUDED.symbol, EXCLUDED.type, EXCLUDED.fiat_value, EXCLUDED.circulating_market_cap, EXCLUDED.volume_24h, EXCLUDED.icon_url) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)",
"(EXCLUDED.name, EXCLUDED.symbol, EXCLUDED.type, EXCLUDED.fiat_value, EXCLUDED.circulating_market_cap, EXCLUDED.volume_24h, EXCLUDED.icon_url, EXCLUDED.decimals) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?)",
token.name,
token.symbol,
token.type,
token.fiat_value,
token.circulating_market_cap,
token.volume_24h,
token.icon_url
token.icon_url,
token.decimals
)
)
end
@@ -297,10 +299,10 @@ defmodule Explorer.Chain.Import.Runner.Tokens do
`:volume_24h`
"""
@spec market_data_fields_to_update() :: [
:name | :symbol | :type | :fiat_value | :circulating_market_cap | :volume_24h
:name | :symbol | :type | :fiat_value | :circulating_market_cap | :volume_24h | :decimals
]
def market_data_fields_to_update do
[:name, :symbol, :type, :fiat_value, :circulating_market_cap, :volume_24h]
[:name, :symbol, :type, :fiat_value, :circulating_market_cap, :volume_24h, :decimals]
end
defp should_update?(_new_token, nil, _fields_to_replace), do: true
+4 -3
View File
@@ -3,6 +3,7 @@ defmodule Explorer.Chain.Token.Schema do
use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]]
alias Explorer.Chain.{Address, Address.Reputation, Hash}
alias Explorer.Chain.Token.FiatValue
if @bridged_tokens_enabled do
@bridged_field [
@@ -28,11 +29,11 @@ defmodule Explorer.Chain.Token.Schema do
field(:skip_metadata, :boolean)
field(:total_supply_updated_at_block, :integer)
field(:metadata_updated_at, :utc_datetime_usec)
field(:fiat_value, :decimal)
field(:circulating_market_cap, :decimal)
field(:fiat_value, FiatValue)
field(:circulating_market_cap, FiatValue)
field(:icon_url, :string)
field(:is_verified_via_admin_panel, :boolean)
field(:volume_24h, :decimal)
field(:volume_24h, FiatValue)
field(:transfer_count, :integer)
belongs_to(
@@ -0,0 +1,60 @@
defmodule Explorer.Chain.Token.FiatValue do
@moduledoc """
Represents money values, used to hide the value if there is a chance that the value is not relevant.
"""
use Ecto.Type
alias Explorer.Market
@type t :: Decimal.t() | nil
@impl Ecto.Type
def type, do: :decimal
@impl Ecto.Type
def cast(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, ""} ->
{:ok, decimal}
_ ->
:error
end
end
@impl Ecto.Type
def cast(value) when is_integer(value) do
{:ok, Decimal.new(value)}
end
@impl Ecto.Type
def cast(value) when is_float(value) do
{:ok, Decimal.from_float(value)}
end
@impl Ecto.Type
def cast(%Decimal{} = decimal) do
{:ok, decimal}
end
@impl Ecto.Type
def cast(_), do: :error
@impl Ecto.Type
def dump(%Decimal{} = decimal) do
{:ok, decimal}
end
@impl Ecto.Type
def dump(_), do: :error
@impl Ecto.Type
def load(%Decimal{} = decimal) do
if Market.token_fetcher_enabled?() do
{:ok, decimal}
else
{:ok, nil}
end
end
end
+1 -1
View File
@@ -660,7 +660,7 @@ defmodule Explorer.Helper do
"""
@spec indexer_node?(Node.t()) :: boolean()
def indexer_node?(node) do
(node |> :rpc.call(Application, :get_env, [:explorer, :mode]) |> process_rpc_response(node, nil)) in [
(node |> :rpc.call(Explorer, :mode, []) |> process_rpc_response(node, nil)) in [
:all,
:indexer
]
@@ -64,11 +64,16 @@ defmodule Explorer.Market.Fetcher.History do
}}
end)
state = %{types_states: types_states}
if Enum.all?(types_states, fn {_type, type_state} -> is_nil(type_state.source) end) do
Logger.info("No market history sources are configured")
:ignore
else
state = %{types_states: types_states}
send(self(), {:fetch_all, config(:first_fetch_day_count)})
send(self(), {:fetch_all, config(:first_fetch_day_count)})
{:ok, state}
{:ok, state}
end
end
def handle_info({:fetch_all, day_count}, state) do
@@ -121,6 +126,7 @@ defmodule Explorer.Market.Fetcher.History do
new_types_states = put_in(state.types_states, [type, :failed_attempts], failed_attempts)
new_state = %{state | types_states: new_types_states}
maybe_insert_and_schedule_refetch(new_state)
{:noreply, new_state}
end
+90 -15
View File
@@ -3,9 +3,68 @@ defmodule Explorer.Market do
Context for data related to the cryptocurrency market.
"""
alias Explorer.Market.Fetcher.Coin
use GenServer
require Logger
alias Explorer.Helper
alias Explorer.Market.Fetcher.Coin, as: CoinFetcher
alias Explorer.Market.Fetcher.History, as: HistoryFetcher
alias Explorer.Market.Fetcher.Token, as: TokenFetcher
alias Explorer.Market.{MarketHistory, MarketHistoryCache, Token}
@history_key :market_history_fetcher_enabled
@tokens_key :market_token_fetcher_enabled
@spec start_link(term()) :: GenServer.on_start()
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl GenServer
def init(_opts) do
if Explorer.mode() == :all do
{history_pid, token_pid} = find_history_and_token_fetchers()
:persistent_term.put(@history_key, !is_nil(history_pid))
:persistent_term.put(@tokens_key, !is_nil(token_pid))
:ignore
else
{:ok, nil, {:continue, 1}}
end
end
@impl GenServer
def handle_continue(attempt, _state) do
attempt |> Kernel.**(3) |> :timer.seconds() |> :timer.sleep()
case Node.list()
|> Enum.filter(&Helper.indexer_node?/1) do
[] ->
if attempt < 5 do
{:noreply, nil, {:continue, attempt + 1}}
else
raise "No indexer nodes discovered after #{attempt} attempts"
end
[indexer] ->
{history_pid, token_pid} =
indexer
|> :rpc.call(__MODULE__, :find_history_and_token_fetchers, [])
|> Helper.process_rpc_response(indexer, {nil, nil})
:persistent_term.put(@history_key, !is_nil(history_pid))
:persistent_term.put(@tokens_key, !is_nil(token_pid))
{:stop, :normal}
multiple_indexers ->
if attempt < 5 do
{:noreply, nil, {:continue, attempt + 1}}
else
raise "Multiple indexer nodes discovered: #{inspect(multiple_indexers)}"
end
end
end
@doc """
Retrieves the history for the recent specified amount of days.
@@ -13,18 +72,11 @@ defmodule Explorer.Market do
"""
@spec fetch_recent_history(boolean()) :: [MarketHistory.t()]
def fetch_recent_history(secondary_coin? \\ false) do
MarketHistoryCache.fetch(secondary_coin?)
end
@doc """
Retrieves today's native coin exchange rate from the database.
"""
@spec get_native_coin_exchange_rate_from_db(boolean()) :: Token.t()
def get_native_coin_exchange_rate_from_db(secondary_coin? \\ false) do
secondary_coin?
|> fetch_recent_history()
|> List.first()
|> MarketHistory.to_token()
if history_fetcher_enabled?() do
MarketHistoryCache.fetch(secondary_coin?)
else
[]
end
end
@doc """
@@ -32,7 +84,7 @@ defmodule Explorer.Market do
"""
@spec get_coin_exchange_rate() :: Token.t()
def get_coin_exchange_rate do
Coin.get_coin_exchange_rate() || get_native_coin_exchange_rate_from_db()
CoinFetcher.get_coin_exchange_rate() || Token.null()
end
@doc """
@@ -40,7 +92,7 @@ defmodule Explorer.Market do
"""
@spec get_secondary_coin_exchange_rate() :: Token.t()
def get_secondary_coin_exchange_rate do
Coin.get_secondary_coin_exchange_rate() || get_native_coin_exchange_rate_from_db(true)
CoinFetcher.get_secondary_coin_exchange_rate() || Token.null()
end
@doc """
@@ -70,4 +122,27 @@ defmodule Explorer.Market do
|> MarketHistory.price_at_date(false, options)
|> MarketHistory.to_token()
end
@doc """
Checks if the market token fetcher is enabled in the application.
This function retrieves the enablement status from persistent term storage
using the `:market_token_fetcher_enabled` key. If the key is not set, it
defaults to `false`.
## Returns
- `true` if the market token fetcher is enabled
- `false` if the market token fetcher is disabled or not configured
"""
@spec token_fetcher_enabled?() :: boolean()
def token_fetcher_enabled? do
:persistent_term.get(@tokens_key, false)
end
@spec history_fetcher_enabled?() :: boolean()
defp history_fetcher_enabled? do
:persistent_term.get(@history_key, false)
end
defp find_history_and_token_fetchers, do: {GenServer.whereis(HistoryFetcher), GenServer.whereis(TokenFetcher)}
end
@@ -41,7 +41,7 @@ defmodule Explorer.Market.MarketHistory do
end
@doc false
@spec bulk_insert([map()]) :: {non_neg_integer(), nil | [term()]}
@spec bulk_insert([map()]) :: {:ok, {non_neg_integer(), nil | [term()]}} | {:error, term()}
def bulk_insert(records) do
records_without_zeroes =
records
@@ -53,10 +53,12 @@ defmodule Explorer.Market.MarketHistory do
# Enforce MarketHistory ShareLocks order (see docs: sharelocks.md)
|> Enum.sort_by(& &1.date)
Repo.safe_insert_all(__MODULE__, records_without_zeroes,
on_conflict: market_history_on_conflict(),
conflict_target: [:date, :secondary_coin]
)
Repo.transaction(fn ->
Repo.safe_insert_all(__MODULE__, records_without_zeroes,
on_conflict: market_history_on_conflict(),
conflict_target: [:date, :secondary_coin]
)
end)
end
@spec to_token(t() | nil) :: Token.t()
+2 -1
View File
@@ -42,6 +42,7 @@ defmodule Explorer.Market.Source do
CryptoCompare,
CryptoRank,
DefiLlama,
DIA,
Mobula
}
@@ -234,7 +235,7 @@ defmodule Explorer.Market.Source do
Decimal.new(value)
end
@sources [CoinGecko, CoinMarketCap, CryptoCompare, CryptoRank, DefiLlama, Mobula]
@sources [CoinGecko, CoinMarketCap, CryptoCompare, CryptoRank, DefiLlama, Mobula, DIA]
@doc """
Returns a module for fetching native coin market data.
@@ -0,0 +1,280 @@
defmodule Explorer.Market.Source.DIA do
@moduledoc """
Adapter for fetching exchange rates from https://www.diadata.org/
"""
require Logger
alias Explorer.Chain.Hash
alias Explorer.Market.{Source, Token}
@behaviour Source
@impl Source
def native_coin_fetching_enabled?, do: not is_nil(config(:coin_address_hash))
@impl Source
def fetch_native_coin, do: do_fetch_coin(config(:coin_address_hash), "Coin address hash not specified")
@impl Source
def secondary_coin_fetching_enabled?, do: not is_nil(config(:secondary_coin_address_hash))
@impl Source
def fetch_secondary_coin,
do: do_fetch_coin(config(:secondary_coin_address_hash), "Secondary coin address hash not specified")
@impl Source
def tokens_fetching_enabled?, do: not is_nil(config(:blockchain))
@impl Source
def fetch_tokens(state, batch_size) when state in [[], nil] do
case init_tokens_fetching() do
{:error, _reason} = error ->
error
tokens_to_fetch when is_list(tokens_to_fetch) and length(tokens_to_fetch) > 0 ->
fetch_tokens(tokens_to_fetch, batch_size)
_ ->
{:error, "Tokens not found for configured blockchain: #{config(:blockchain)}"}
end
end
@impl Source
def fetch_tokens(state, batch_size) do
# it safe to pattern match here because init_tokens_fetching/0
# would have returned an error otherwise
blockchain = config(:blockchain)
{to_fetch, remaining} = Enum.split(state, batch_size)
tasks_results =
to_fetch
|> Task.async_stream(
fn token ->
case Source.http_request(
base_url()
|> URI.append_path("/assetQuotation")
|> URI.append_path("/#{blockchain}")
|> URI.append_path("/#{token.contract_address_hash}")
|> URI.to_string(),
[]
) do
{:ok, data} ->
token_to_import =
Map.merge(token, %{
symbol: data["Symbol"],
name: data["Name"],
fiat_value: Source.to_decimal(data["Price"]),
volume_24h: Source.to_decimal(data["VolumeYesterdayUSD"]),
type: "ERC-20"
})
{:ok, token_to_import}
{:error, reason} ->
{:error, {token, reason}}
end
end,
max_concurrency: 5,
timeout: :timer.seconds(60),
zip_input_on_exit: true
)
|> Enum.group_by(
fn
{:ok, {:ok, _}} -> :ok
_ -> :error
end,
fn
{:ok, {:ok, token_to_import}} -> token_to_import
{:ok, {:error, {token, reason}}} -> {token, reason}
{:error, {token, reason}} -> {token, reason}
end
)
to_import = Map.get(tasks_results, :ok, [])
tokens_with_errors = Map.get(tasks_results, :error, [])
remaining_with_errors =
if Enum.empty?(tokens_with_errors) do
remaining
else
Logger.error("Errors while fetching tokens from DIA: #{inspect(tokens_with_errors)}")
remaining ++ Enum.map(tokens_with_errors, fn {token, _reason} -> token end)
end
if Enum.empty?(to_import) and !Enum.empty?(tokens_with_errors) do
{:error, tokens_with_errors}
else
{:ok, remaining_with_errors, Enum.empty?(remaining_with_errors), to_import}
end
end
@impl Source
def native_coin_price_history_fetching_enabled?, do: not is_nil(config(:coin_address_hash))
@impl Source
def fetch_native_coin_price_history(previous_days), do: do_fetch_coin_price_history(previous_days, false)
@impl Source
def secondary_coin_price_history_fetching_enabled?, do: not is_nil(config(:secondary_coin_address_hash))
@impl Source
def fetch_secondary_coin_price_history(previous_days), do: do_fetch_coin_price_history(previous_days, true)
@impl Source
def market_cap_history_fetching_enabled?, do: :ignore
@impl Source
def fetch_market_cap_history(_previous_days), do: :ignore
@impl Source
def tvl_history_fetching_enabled?, do: :ignore
@impl Source
def fetch_tvl_history(_previous_days), do: :ignore
defp do_fetch_coin(coin_address_hash, coin_address_hash_not_specified_error) do
with {:coin, coin_address_hash} when not is_nil(coin_address_hash) <- {:coin, coin_address_hash},
{:blockchain, blockchain} when not is_nil(blockchain) <- {:blockchain, config(:blockchain)},
{:ok, %{"Price" => _price} = data} <-
Source.http_request(
base_url()
|> URI.append_path("/assetQuotation/#{blockchain}/#{coin_address_hash}")
|> URI.to_string(),
[]
) do
{:ok,
%Token{
available_supply: nil,
total_supply: nil,
btc_value: nil,
last_updated: Source.maybe_get_date(data["Time"]),
market_cap: nil,
tvl: nil,
name: data["Name"],
symbol: data["Symbol"],
fiat_value: Source.to_decimal(data["Price"]),
volume_24h: Source.to_decimal(data["VolumeYesterdayUSD"]),
image_url: nil
}}
else
{:coin, nil} -> {:error, coin_address_hash_not_specified_error}
{:blockchain, nil} -> {:error, "Blockchain not specified"}
{:ok, unexpected_response} -> {:error, Source.unexpected_response_error("DIA", unexpected_response)}
{:error, _reason} = error -> error
end
end
defp init_tokens_fetching do
with blockchain when not is_nil(blockchain) <- config(:blockchain),
coin_address_hash = config(:coin_address_hash),
{:ok, tokens} <-
Source.http_request(
base_url()
|> URI.append_path("/quotedAssets")
|> URI.append_query("blockchain=#{blockchain}")
|> URI.to_string(),
[]
) do
tokens
|> Enum.reduce([], fn
%{
"Asset" => %{
"Address" => token_contract_address_hash_string,
"Decimals" => decimals
}
},
acc ->
case (is_nil(coin_address_hash) ||
String.downcase(token_contract_address_hash_string) != String.downcase(coin_address_hash)) &&
Hash.Address.cast(token_contract_address_hash_string) do
{:ok, token_contract_address_hash} ->
token = %{
contract_address_hash: token_contract_address_hash,
decimals: decimals
}
[token | acc]
_ ->
acc
end
_, acc ->
acc
end)
else
nil -> {:error, "Blockchain not specified"}
{:error, reason} -> {:error, reason}
end
end
defp do_fetch_coin_price_history(previous_days, secondary_coin?) do
datetime_now = DateTime.utc_now()
unix_now = datetime_now |> DateTime.to_unix()
unix_from = unix_now - previous_days * 24 * 60 * 60
with {:coin, coin_address_hash} when not is_nil(coin_address_hash) <-
{:coin, if(secondary_coin?, do: config(:secondary_coin_address_hash), else: config(:coin_address_hash))},
{:blockchain, blockchain} when not is_nil(blockchain) <- {:blockchain, config(:blockchain)},
{:ok, %{"DataPoints" => [%{"Series" => [%{"values" => values}]}]}} <-
Source.http_request(
base_url()
|> URI.append_path("/assetChartPoints/MA120/#{blockchain}/#{coin_address_hash}")
|> URI.append_query("starttime=#{unix_from}")
|> URI.append_query("endtime=#{unix_now}")
|> URI.to_string(),
[]
) do
values
|> Enum.reduce_while(%{}, fn value, acc ->
with time when not is_nil(time) <- List.first(value),
{:ok, datetime, _} <- DateTime.from_iso8601(time),
date = DateTime.to_date(datetime),
price when not is_nil(price) <- List.last(value) do
{:cont,
Map.update(
acc,
date,
%{
closing_price: Source.to_decimal(price),
date: date,
opening_price: Source.to_decimal(price),
secondary_coin: secondary_coin?
},
fn existing_entry ->
%{
existing_entry
| opening_price: Source.to_decimal(price)
}
end
)}
else
_ ->
{:halt, {:error, "Wrong format of DIA coin price history response: #{inspect(value)}"}}
end
end)
|> case do
{:error, _reason} = error ->
error
price_history_map ->
{:ok, Map.values(price_history_map)}
end
else
{:coin, nil} -> {:error, "#{Source.secondary_coin_string(secondary_coin?)} address hash not specified"}
{:blockchain, nil} -> {:error, "Blockchain not specified"}
{:ok, _} -> {:ok, []}
{:error, _reason} = error -> error
end
end
defp base_url do
:base_url |> config() |> URI.parse()
end
@spec config(atom()) :: term
defp config(key) do
Application.get_env(:explorer, __MODULE__)[key]
end
end
@@ -66,7 +66,10 @@ defmodule Explorer.Market.Source.Mobula do
end
end)
{:ok, offset + batch_size, initial_tokens_len < batch_size, tokens_to_import}
fetch_finished? = initial_tokens_len < batch_size
new_state = if fetch_finished?, do: nil, else: offset + batch_size
{:ok, new_state, fetch_finished?, tokens_to_import}
else
nil -> {:error, "Platform ID not specified"}
{:ok, unexpected_response} -> {:error, Source.unexpected_response_error("Mobula", unexpected_response)}
@@ -292,6 +292,15 @@ defmodule Explorer.Chain.Address.TokenTest do
end
describe "page_tokens/2" do
setup do
initial_value = :persistent_term.get(:market_token_fetcher_enabled, false)
:persistent_term.put(:market_token_fetcher_enabled, true)
on_exit(fn ->
:persistent_term.put(:market_token_fetcher_enabled, initial_value)
end)
end
test "just bring the normal query when PagingOptions.key is nil" do
options = %PagingOptions{key: nil}
@@ -148,4 +148,43 @@ defmodule Explorer.Chain.TokenTest do
assert {:ok, _updated_token} = Token.update(token, update_params)
end
end
describe "list_top/2" do
test "returns market data with enabled token fetcher" do
old_source_env = Application.get_env(:explorer, Explorer.Market.Source)
old_fetcher_env = Application.get_env(:explorer, Explorer.Market.Fetcher.Token)
Application.put_env(
:explorer,
Explorer.Market.Source,
Keyword.merge(old_source_env, tokens_source: Explorer.Market.Source.OneCoinSource)
)
Application.put_env(:explorer, Explorer.Market.Fetcher.Token, Keyword.merge(old_fetcher_env, enabled: true))
on_exit(fn ->
Application.put_env(:explorer, Explorer.Market.Source, old_source_env)
Application.put_env(:explorer, Explorer.Market.Fetcher.Token, old_fetcher_env)
end)
start_supervised!(Explorer.Market.Fetcher.Token)
start_supervised!(Explorer.Market)
insert_list(10, :token)
market_data = Token.list_top(nil)
assert Enum.all?(market_data, fn token -> not is_nil(token.fiat_value) end)
end
test "ignores market data with disabled token fetcher" do
insert_list(10, :token)
start_supervised!(Explorer.Market)
market_data = Token.list_top(nil)
assert Enum.all?(market_data, fn token -> is_nil(token.fiat_value) end)
end
end
end
@@ -11,14 +11,14 @@ defmodule Explorer.Market.MarketHistoryTest do
%{date: ~D[2023-01-02], opening_price: Decimal.new("7.50"), closing_price: Decimal.new("5.00")}
]
assert {2, _} = MarketHistory.bulk_insert(records)
assert {:ok, {2, _}} = MarketHistory.bulk_insert(records)
records = [
%{date: ~D[2023-01-01], opening_price: Decimal.new("50.00"), closing_price: Decimal.new("55.00")},
%{date: ~D[2023-01-02], opening_price: Decimal.new("10.00"), closing_price: Decimal.new("8.00")}
]
assert {2, _} = MarketHistory.bulk_insert(records)
assert {:ok, {2, _}} = MarketHistory.bulk_insert(records)
assert [date1, date2] = Repo.all(MarketHistory)
assert date1.opening_price == Decimal.new("50.00")
@@ -3,7 +3,6 @@ defmodule Explorer.MarketTest do
alias Explorer.Market
alias Explorer.Market.MarketHistory
alias Explorer.Repo
setup do
Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id())
@@ -17,24 +16,123 @@ defmodule Explorer.MarketTest do
:ok
end
test "fetch_recent_history/1" do
ConCache.delete(:market_history, :last_update)
describe "fetch_recent_history/1" do
test "with enabled history fetcher" do
ConCache.delete(:market_history, :last_update)
today = Date.utc_today()
start_supervised!(Explorer.Market.Fetcher.History)
start_supervised!(Explorer.Market)
records =
for i <- 0..29 do
%{
date: Timex.shift(today, days: i * -1),
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
}
end
today = Date.utc_today()
MarketHistory.bulk_insert(records)
records =
for i <- 0..29 do
%{
date: Timex.shift(today, days: i * -1),
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
}
end
history = Market.fetch_recent_history()
assert length(history) == 30
assert Enum.at(history, 0).date == Enum.at(records, 0).date
MarketHistory.bulk_insert(records)
history = Market.fetch_recent_history()
assert length(history) == 30
assert Enum.at(history, 0).date == Enum.at(records, 0).date
end
test "with disabled history fetcher" do
ConCache.delete(:market_history, :last_update)
start_supervised!(Explorer.Market)
old_env = Application.get_env(:explorer, Explorer.Market.Fetcher.History)
Application.put_env(:explorer, Explorer.Market.Fetcher.History, Keyword.merge(old_env, enabled: false))
on_exit(fn ->
Application.put_env(:explorer, Explorer.Market.Fetcher.History, old_env)
end)
today = Date.utc_today()
records =
for i <- 0..29 do
%{
date: Timex.shift(today, days: i * -1),
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
}
end
MarketHistory.bulk_insert(records)
history = Market.fetch_recent_history()
assert length(history) == 0
end
end
describe "get_coin_exchange_rate/0" do
test "with enabled coin fetcher" do
old_source_env = Application.get_env(:explorer, Explorer.Market.Source)
old_fetcher_env = Application.get_env(:explorer, Explorer.Market.Fetcher.Coin)
Application.put_env(
:explorer,
Explorer.Market.Source,
Keyword.merge(old_source_env, native_coin_source: Explorer.Market.Source.OneCoinSource)
)
Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, Keyword.merge(old_fetcher_env, enabled: true))
on_exit(fn ->
Application.put_env(:explorer, Explorer.Market.Source, old_source_env)
Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, old_fetcher_env)
end)
start_supervised!(Explorer.Market.Fetcher.Coin)
:timer.sleep(100)
exchange_rate = Market.get_coin_exchange_rate()
assert exchange_rate.fiat_value == Decimal.new(1)
end
test "with disabled coin fetcher" do
exchange_rate = Market.get_coin_exchange_rate()
assert exchange_rate.fiat_value == nil
end
end
describe "get_secondary_coin_exchange_rate/0" do
test "with enabled coin fetcher" do
old_source_env = Application.get_env(:explorer, Explorer.Market.Source)
old_fetcher_env = Application.get_env(:explorer, Explorer.Market.Fetcher.Coin)
Application.put_env(
:explorer,
Explorer.Market.Source,
Keyword.merge(old_source_env, secondary_coin_source: Explorer.Market.Source.OneCoinSource)
)
Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, Keyword.merge(old_fetcher_env, enabled: true))
on_exit(fn ->
Application.put_env(:explorer, Explorer.Market.Source, old_source_env)
Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, old_fetcher_env)
end)
start_supervised!(Explorer.Market.Fetcher.Coin)
:timer.sleep(100)
exchange_rate = Market.get_secondary_coin_exchange_rate()
assert exchange_rate.fiat_value == Decimal.new(1)
end
test "with disabled coin fetcher" do
exchange_rate = Market.get_secondary_coin_exchange_rate()
assert exchange_rate.fiat_value == nil
end
end
end
@@ -106,11 +106,11 @@ defmodule Explorer.Market.Source.CryptoRankTest do
end
describe "tokens_fetching_enabled?" do
test "returns true if coin_id is configured" do
test "returns true if platform is configured" do
assert CryptoRank.tokens_fetching_enabled?()
end
test "returns false if coin_id is not configured", %{old_env: old_env} do
test "returns false if platform is not configured", %{old_env: old_env} do
Application.put_env(:explorer, CryptoRank, Keyword.merge(old_env, platform: nil))
refute CryptoRank.tokens_fetching_enabled?()
@@ -0,0 +1,437 @@
defmodule Explorer.Market.Source.DIATest do
use ExUnit.Case
alias Explorer.Market.Source.DIA
alias Plug.Conn
@native_coin_history_json File.read!("./test/support/fixture/market/dia/native_coin_history.json")
@secondary_coin_history_json File.read!("./test/support/fixture/market/dia/secondary_coin_history.json")
setup do
bypass = Bypass.open()
old_env = Application.get_env(:explorer, DIA, [])
new_env =
Keyword.merge(
old_env,
blockchain: "Ethereum",
base_url: "http://localhost:#{bypass.port}",
coin_address_hash: "0x0000000000000000000000000000000000000000",
secondary_coin_address_hash: "0x0000000000000000000000000000000000000001"
)
Application.put_env(
:explorer,
DIA,
new_env
)
Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint)
on_exit(fn ->
Application.put_env(:explorer, DIA, old_env)
Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter)
end)
{:ok, env: new_env, bypass: bypass}
end
describe "native_coin_fetching_enabled?" do
test "returns true if coin_address_hash is configured" do
assert DIA.native_coin_fetching_enabled?()
end
test "returns false if coin_address_hash is not configured", %{env: env} do
Application.put_env(:explorer, DIA, Keyword.merge(env, coin_address_hash: nil))
refute DIA.native_coin_fetching_enabled?()
end
end
describe "fetch_native_coin" do
test "fetches native coin", %{bypass: bypass} do
Bypass.expect_once(
bypass,
"GET",
"/assetQuotation/Ethereum/0x0000000000000000000000000000000000000000",
fn conn ->
Conn.resp(
conn,
200,
json_coin("0x0000000000000000000000000000000000000000", 3027.98578732332, "ETH", "Ethereum")
)
end
)
assert {:ok,
%Explorer.Market.Token{
available_supply: nil,
btc_value: nil,
fiat_value: Decimal.new("3027.98578732332"),
image_url: nil,
last_updated: ~U[2025-11-27 08:09:59Z],
market_cap: nil,
name: "Ethereum",
symbol: "ETH",
total_supply: nil,
tvl: nil,
volume_24h: Decimal.new("6577658642.868258")
}} ==
DIA.fetch_native_coin()
end
end
describe "secondary_coin_fetching_enabled?" do
test "returns true if secondary_coin_address_hash is configured" do
assert DIA.secondary_coin_fetching_enabled?()
end
test "returns false if secondary_coin_address_hash is not configured", %{env: env} do
Application.put_env(:explorer, DIA, Keyword.merge(env, secondary_coin_address_hash: nil))
refute DIA.secondary_coin_fetching_enabled?()
end
end
describe "fetch_secondary_coin" do
test "fetches secondary coin", %{bypass: bypass} do
Bypass.expect_once(
bypass,
"GET",
"/assetQuotation/Ethereum/0x0000000000000000000000000000000000000001",
fn conn ->
Conn.resp(
conn,
200,
json_coin("0x0000000000000000000000000000000000000001", 91169.97153780921, "WBTC", "Wrapped BTC")
)
end
)
assert {:ok,
%Explorer.Market.Token{
available_supply: nil,
btc_value: nil,
fiat_value: Decimal.new("91169.97153780921"),
image_url: nil,
last_updated: ~U[2025-11-27 08:09:59Z],
market_cap: nil,
name: "Wrapped BTC",
symbol: "WBTC",
total_supply: nil,
tvl: nil,
volume_24h: Decimal.new("6577658642.868258")
}} ==
DIA.fetch_secondary_coin()
end
end
describe "tokens_fetching_enabled?" do
test "returns true if blockchain is configured" do
assert DIA.tokens_fetching_enabled?()
end
test "returns false if blockchain is not configured", %{env: env} do
Application.put_env(:explorer, DIA, Keyword.merge(env, blockchain: nil))
refute DIA.tokens_fetching_enabled?()
end
end
describe "fetch_tokens" do
test "fetches tokens", %{bypass: bypass} do
Bypass.expect(bypass, "GET", "/quotedAssets", fn conn ->
assert conn.query_string == "blockchain=Ethereum"
Conn.resp(conn, 200, json_tokens_list())
end)
Bypass.expect(bypass, "GET", "/assetQuotation/Ethereum/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", fn conn ->
Conn.resp(
conn,
200,
json_coin("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 0.9997188333561603, "USDC", "USD Coin")
)
end)
Bypass.expect(bypass, "GET", "/assetQuotation/Ethereum/0xdac17f958d2ee523a2206206994597c13d831ec7", fn conn ->
Conn.resp(
conn,
200,
json_coin("0xdAC17F958D2ee523a2206206994597C13D831ec7", 0.9999313493241913, "USDT", "Tether USD")
)
end)
usdt_and_usdc_to_fetch = [
%{
contract_address_hash: %Explorer.Chain.Hash{
byte_count: 20,
bytes: "\xDA\xC1\d\x95\x8D.\xE5#\xA2 b\x06\x99E\x97\xC1=\x83\x1E\xC7"
},
decimals: 6
},
%{
contract_address_hash: %Explorer.Chain.Hash{
byte_count: 20,
bytes: "\xA0\xB8i\x91\xC6!\x8B6\xC1ѝJ.\x9E\xB0\xCE6\x06\xEBH"
},
decimals: 6
}
]
usdt_and_usdc = [
%{
name: "Tether USD",
type: "ERC-20",
symbol: "USDT",
contract_address_hash: %Explorer.Chain.Hash{
byte_count: 20,
bytes: <<218, 193, 127, 149, 141, 46, 229, 35, 162, 32, 98, 6, 153, 69, 151, 193, 61, 131, 30, 199>>
},
fiat_value: Decimal.new("0.9999313493241913"),
volume_24h: Decimal.new("6577658642.868258"),
decimals: 6
},
%{
name: "USD Coin",
type: "ERC-20",
symbol: "USDC",
contract_address_hash: %Explorer.Chain.Hash{
byte_count: 20,
bytes: <<160, 184, 105, 145, 198, 33, 139, 54, 193, 209, 157, 74, 46, 158, 176, 206, 54, 6, 235, 72>>
},
fiat_value: Decimal.new("0.9997188333561603"),
volume_24h: Decimal.new("6577658642.868258"),
decimals: 6
}
]
assert {:ok, [usdt_or_usdc_to_fetch] = state, false, [usdc_or_usdt_a]} = DIA.fetch_tokens(nil, 1)
assert usdt_or_usdc_to_fetch in usdt_and_usdc_to_fetch
assert {:ok, [], true, [usdc_or_usdt_b]} = DIA.fetch_tokens(state, 1)
assert usdc_or_usdt_a in usdt_and_usdc
assert usdc_or_usdt_b in usdt_and_usdc
assert usdc_or_usdt_a != usdc_or_usdt_b
end
end
describe "native_coin_price_history_fetching_enabled?" do
test "returns true if coin_address_hash is configured" do
assert DIA.native_coin_price_history_fetching_enabled?()
end
test "returns false if coin_address_hash is not configured", %{env: env} do
Application.put_env(:explorer, DIA, Keyword.merge(env, coin_address_hash: nil))
refute DIA.native_coin_price_history_fetching_enabled?()
end
end
describe "fetch_native_coin_price_history" do
test "fetches native coin price history", %{bypass: bypass} do
previous_days = 5
Bypass.expect_once(
bypass,
"GET",
"/assetChartPoints/MA120/Ethereum/0x0000000000000000000000000000000000000000",
fn conn ->
"starttime=" <> from_str = conn.query_string
{from_int, "&endtime=" <> to_str} = Integer.parse(from_str)
{to_int, ""} = Integer.parse(to_str)
assert to_int - from_int == previous_days * 24 * 60 * 60
Conn.resp(conn, 200, @native_coin_history_json)
end
)
assert {:ok,
[
%{
closing_price: Decimal.new("2769.8064113406717"),
date: ~D[2025-11-22],
opening_price: Decimal.new("2765.03927910001"),
secondary_coin: false
},
%{
closing_price: Decimal.new("2801.5141364778983"),
date: ~D[2025-11-23],
opening_price: Decimal.new("2768.2062977060264"),
secondary_coin: false
},
%{
closing_price: Decimal.new("2952.88025966878"),
date: ~D[2025-11-24],
opening_price: Decimal.new("2798.783002464827"),
secondary_coin: false
},
%{
closing_price: Decimal.new("2959.1210339664754"),
date: ~D[2025-11-25],
opening_price: Decimal.new("2949.9206856724004"),
secondary_coin: false
},
%{
closing_price: Decimal.new("3027.151520522141"),
date: ~D[2025-11-26],
opening_price: Decimal.new("2959.062301250391"),
secondary_coin: false
}
]} ==
DIA.fetch_native_coin_price_history(previous_days)
end
end
describe "secondary_coin_price_history_fetching_enabled?" do
test "returns true if secondary_coin_address_hash is configured" do
assert DIA.secondary_coin_price_history_fetching_enabled?()
end
test "returns false if secondary_coin_address_hash is not configured", %{env: env} do
Application.put_env(:explorer, DIA, Keyword.merge(env, secondary_coin_address_hash: nil))
refute DIA.secondary_coin_price_history_fetching_enabled?()
end
end
describe "fetch_secondary_coin_price_history" do
test "fetches secondary coin price history", %{bypass: bypass} do
previous_days = 5
Bypass.expect_once(
bypass,
"GET",
"/assetChartPoints/MA120/Ethereum/0x0000000000000000000000000000000000000001",
fn conn ->
"starttime=" <> from_str = conn.query_string
{from_int, "&endtime=" <> to_str} = Integer.parse(from_str)
{to_int, ""} = Integer.parse(to_str)
assert to_int - from_int == previous_days * 24 * 60 * 60
Conn.resp(conn, 200, @secondary_coin_history_json)
end
)
assert {:ok,
[
%{
closing_price: Decimal.new("1.0014961501515858"),
date: ~D[2025-11-22],
opening_price: Decimal.new("0.9998295379999973"),
secondary_coin: true
},
%{
closing_price: Decimal.new("1.0003179715114685"),
date: ~D[2025-11-23],
opening_price: Decimal.new("0.9998108086782626"),
secondary_coin: true
},
%{
closing_price: Decimal.new("0.9999003120252966"),
date: ~D[2025-11-24],
opening_price: Decimal.new("1.0000636244141254"),
secondary_coin: true
},
%{
closing_price: Decimal.new("0.9997550066438543"),
date: ~D[2025-11-25],
opening_price: Decimal.new("1.0006631013107907"),
secondary_coin: true
},
%{
closing_price: Decimal.new("0.9997050546597661"),
date: ~D[2025-11-26],
opening_price: Decimal.new("0.9998251538809131"),
secondary_coin: true
}
]} ==
DIA.fetch_secondary_coin_price_history(previous_days)
end
end
describe "market_cap_history_fetching_enabled?" do
test "ignored" do
assert DIA.market_cap_history_fetching_enabled?() == :ignore
end
end
describe "fetch_market_cap_history" do
test "ignored" do
assert DIA.fetch_market_cap_history(0) == :ignore
end
end
describe "tvl_history_fetching_enabled?" do
test "ignored" do
assert DIA.tvl_history_fetching_enabled?() == :ignore
end
end
describe "fetch_tvl_history" do
test "ignored" do
assert DIA.fetch_tvl_history(0) == :ignore
end
end
# cspell:disable
defp json_coin(coin_address_hash, fiat_value, symbol, name) do
"""
{
"Symbol": "#{symbol}",
"Name": "#{name}",
"Address": "#{coin_address_hash}",
"Blockchain": "Ethereum",
"Price": #{fiat_value},
"PriceYesterday": 2935.2004802640054,
"VolumeYesterdayUSD": 6577658642.868258,
"Time": "2025-11-27T08:09:59Z",
"Source": "diadata.org",
"Signature": "0x5b080262b6ee4f5303251ef65dc81b61dc6e5ceb6e2ff0d9ca8a6c9175c0bef472bbadfe4a3a339134ebc094b4336fb6ad0e50bac3622968bb03819789b6dbea01"
}
"""
end
defp json_tokens_list do
"""
[
{
"Asset": {
"Symbol": "ETH",
"Name": "Ether",
"Address": "0x0000000000000000000000000000000000000000",
"Decimals": 18,
"Blockchain": "Ethereum"
},
"Volume": 6577658642.868258,
"VolumeUSD": 0,
"Index": 0
},
{
"Asset": {
"Symbol": "USDC",
"Name": "USD Coin",
"Address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"Decimals": 6,
"Blockchain": "Ethereum"
},
"Volume": 2756384237.21397,
"VolumeUSD": 0,
"Index": 0
},
{
"Asset": {
"Symbol": "USDT",
"Name": "Tether USD",
"Address": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"Decimals": 6,
"Blockchain": "Ethereum"
},
"Volume": 504424765.759824,
"VolumeUSD": 0,
"Index": 0
}
]
"""
end
# cspell:enable
end
@@ -0,0 +1,54 @@
defmodule Explorer.BenchmarkCase do
@moduledoc """
This module defines the benchmark case to be used by benchmarks.
This module provides common setup and utilities for benchmarking,
similar to DataCase but optimized for benchmarking needs, including:
- Database sandbox management
- Factory imports
- Common helpers
## Example
```elixir
defmodule Explorer.MyBenchmark do
use Explorer.BenchmarkCase
def run do
Benchee.run(...)
end
end
# Run the benchmark
Explorer.MyBenchmark.run()
```
"""
defmacro __using__(_opts) do
caller_file = __CALLER__.file
path = caller_file |> String.replace(Path.extname(caller_file), ".benchee")
quote do
import Explorer.Factory
@path unquote(path)
@doc """
Resets the database for consistent benchmarks
"""
def reset_db do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo, ownership_timeout: :infinity)
end
@doc """
Setup common benchmark environment
"""
def benchmark_setup do
{:ok, _} = Application.ensure_all_started(:explorer)
:ok
end
end
end
end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -105,7 +105,7 @@ defmodule Indexer.Memory.Monitor do
default_limit = 1 <<< 30
percentage =
case Application.get_env(:explorer, :mode) do
case Explorer.mode() do
:indexer -> 100
_ -> Application.get_env(:indexer, :system_memory_percentage)
end
@@ -17,7 +17,25 @@ defmodule NFTMediaHandler.DispatcherInterface do
"""
@impl true
def init(_) do
{:ok, %{used_nodes: [], unused_nodes: []}}
{:ok, nil, {:continue, 1}}
end
@impl true
def handle_continue(attempt, _state) do
attempt |> Kernel.**(3) |> :timer.seconds() |> :timer.sleep()
get_indexer_nodes()
|> case do
[] ->
if attempt < 5 do
{:noreply, nil, {:continue, attempt + 1}}
else
raise "No indexer nodes discovered after #{attempt} attempts"
end
nodes ->
{:noreply, %{used_nodes: [], unused_nodes: nodes}}
end
end
@doc """
@@ -29,8 +47,7 @@ defmodule NFTMediaHandler.DispatcherInterface do
{used, unused, node_to_call} =
case unused_nodes do
[] ->
Node.list()
|> Enum.filter(&Helper.indexer_node?/1)
get_indexer_nodes()
|> case do
[] ->
raise "No indexer nodes discovered"
@@ -88,4 +105,9 @@ defmodule NFTMediaHandler.DispatcherInterface do
defp remote_call(args, function, _node, false) do
apply(Indexer.NFTMediaHandler.Queue, function, args)
end
defp get_indexer_nodes do
Node.list()
|> Enum.filter(&Helper.indexer_node?/1)
end
end
+2
View File
@@ -275,6 +275,7 @@ defmodule ConfigHelper do
| Source.CryptoCompare
| Source.CryptoRank
| Source.DefiLlama
| Source.DIA
| Source.Mobula
| nil
def market_source(env_var) do
@@ -284,6 +285,7 @@ defmodule ConfigHelper do
"crypto_compare" => Source.CryptoCompare,
"crypto_rank" => Source.CryptoRank,
"defillama" => Source.DefiLlama,
"dia" => Source.DIA,
"mobula" => Source.Mobula,
"" => nil,
nil => nil
+9 -1
View File
@@ -401,7 +401,7 @@ config :explorer, Explorer.Chain.Cache.Counters.AverageBlockTime,
num_of_blocks: ConfigHelper.parse_integer_env_var("CACHE_AVERAGE_BLOCK_TIME_WINDOW", 100)
config :explorer, Explorer.Market.MarketHistoryCache,
cache_period: ConfigHelper.parse_time_env_var("CACHE_MARKET_HISTORY_PERIOD", "6h")
cache_period: ConfigHelper.parse_time_env_var("CACHE_MARKET_HISTORY_PERIOD", "1h")
config :explorer, Explorer.Chain.Cache.Counters.AddressTransactionsCount,
cache_period: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TRANSACTIONS_COUNTER_PERIOD", "1h")
@@ -427,6 +427,8 @@ config :explorer, Explorer.Chain.Cache.Counters.NewPendingTransactionsCount,
cache_period: ConfigHelper.parse_time_env_var("CACHE_FRESH_PENDING_TRANSACTIONS_COUNTER_PERIOD", "5m"),
enable_consolidation: true
config :explorer, Explorer.Market, enabled: !disable_exchange_rates?
config :explorer, Explorer.Market.Source,
native_coin_source:
ConfigHelper.market_source("MARKET_NATIVE_COIN_SOURCE") || ConfigHelper.market_source("EXCHANGE_RATES_SOURCE"),
@@ -508,6 +510,12 @@ config :explorer, Explorer.Market.Source.Mobula,
secondary_coin_id:
System.get_env("MARKET_MOBULA_SECONDARY_COIN_ID") || System.get_env("EXCHANGE_RATES_MOBULA_SECONDARY_COIN_ID")
config :explorer, Explorer.Market.Source.DIA,
blockchain: System.get_env("MARKET_DIA_BLOCKCHAIN"),
base_url: System.get_env("MARKET_DIA_BASE_URL", "https://api.diadata.org/v1"),
coin_address_hash: System.get_env("MARKET_DIA_COIN_ADDRESS_HASH"),
secondary_coin_address_hash: System.get_env("MARKET_DIA_SECONDARY_COIN_ADDRESS_HASH")
config :explorer, Explorer.Market.Fetcher.Coin,
store: :ets,
enabled: !disable_exchange_rates? && ConfigHelper.parse_bool_env_var("MARKET_COIN_FETCHER_ENABLED", "true"),
@@ -82,6 +82,10 @@ DISABLE_MARKET=true
# MARKET_MOBULA_API_KEY=
# MARKET_MOBULA_COIN_ID=
# MARKET_MOBULA_SECONDARY_COIN_ID=
# MARKET_DIA_BLOCKCHAIN=
# MARKET_DIA_BASE_URL=
# MARKET_DIA_COIN_ADDRESS_HASH=
# MARKET_DIA_SECONDARY_COIN_ADDRESS_HASH=
# MARKET_COIN_FETCHER_ENABLED=
# MARKET_COIN_CACHE_PERIOD=
# MARKET_TOKENS_FETCHER_ENABLED=