API v2 for frontend (#6379)
* Core BlockScout API V2 (#6164) API endpoints: /transactions /transactions/{tx_hash} /transactions/{tx_hash}/token-transfers /transactions/{tx_hash}/internal-transactions /transactions/{tx_hash}/logs /transactions/{tx_hash}/raw-trace * Block initial commit * Finish Block API * Address API initial commit * Fix some issue with API * Add tags * transaction -> coin_transfer * Some changes * Add /transactions and /token-transfers for addresses * Fix test * Fix tests * Fix block rewards * Add /json-rpc-url API endpoint * Create method_id index concurrently * Fix for concurrent index creation * BS core API V2: addresses, stats, main page, websockets (#6361) * socket/v2 * Refactor: reuse fees counter fucntion, remove unecessary clause of do_token_transfer_amount_for_api * Improve token transfers preload * Done with channels * Add some endpoints to /address * Fix credo * Add main page controller * Add stats controller * Move api search to API v2 * Fix some addresses methods; Rename gas_price * Improve logs view * Fix tests * Brush and finalize websockets * Add API_V2_ENABLED env * Fix credo * Add is_smart_contract clause * Add CHANGELOG entry Co-authored-by: nikitosing <32202610+nikitosing@users.noreply.github.com> Co-authored-by: Никита Поздняков <nikitosing4@mail.ru>pull/5944/head
parent
2c438e6077
commit
3cac89f15c
@ -0,0 +1,19 @@ |
||||
defmodule BlockScoutWeb.UserSocketV2 do |
||||
@moduledoc """ |
||||
Module to distinct new and old UI websocket connections |
||||
""" |
||||
use Phoenix.Socket |
||||
|
||||
channel("addresses:*", BlockScoutWeb.AddressChannel) |
||||
channel("blocks:*", BlockScoutWeb.BlockChannel) |
||||
channel("exchange_rate:*", BlockScoutWeb.ExchangeRateChannel) |
||||
channel("rewards:*", BlockScoutWeb.RewardChannel) |
||||
channel("transactions:*", BlockScoutWeb.TransactionChannel) |
||||
channel("tokens:*", BlockScoutWeb.TokenChannel) |
||||
|
||||
def connect(_params, socket) do |
||||
{:ok, socket} |
||||
end |
||||
|
||||
def id(_socket), do: nil |
||||
end |
@ -0,0 +1,238 @@ |
||||
defmodule BlockScoutWeb.API.V2.AddressController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
import BlockScoutWeb.Chain, |
||||
only: [ |
||||
next_page_params: 3, |
||||
paging_options: 1, |
||||
split_list_by_page: 1, |
||||
current_filter: 1 |
||||
] |
||||
|
||||
alias BlockScoutWeb.API.V2.{AddressView, BlockView, TransactionView} |
||||
alias Explorer.{Chain, Market} |
||||
alias Indexer.Fetcher.TokenBalanceOnDemand |
||||
|
||||
@transaction_necessity_by_association [ |
||||
necessity_by_association: %{ |
||||
[created_contract_address: :names] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
:block => :optional, |
||||
[created_contract_address: :smart_contract] => :optional, |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional |
||||
} |
||||
] |
||||
|
||||
@transaction_with_tt_necessity_by_association [ |
||||
necessity_by_association: %{ |
||||
[created_contract_address: :names] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
[created_contract_address: :smart_contract] => :optional, |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional, |
||||
[token_transfers: :token] => :optional, |
||||
[token_transfers: :to_address] => :optional, |
||||
[token_transfers: :from_address] => :optional, |
||||
[token_transfers: :token_contract_address] => :optional, |
||||
:block => :required |
||||
} |
||||
] |
||||
|
||||
action_fallback(BlockScoutWeb.API.V2.FallbackController) |
||||
|
||||
def address(conn, %{"address_hash" => address_hash_string}) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, |
||||
{:not_found, {:ok, address}} <- {:not_found, Chain.hash_to_address(address_hash)} do |
||||
conn |
||||
|> put_status(200) |
||||
|> render(:address, %{address: address}) |
||||
end |
||||
end |
||||
|
||||
def token_balances(conn, %{"address_hash" => address_hash_string}) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
token_balances = |
||||
address_hash |
||||
|> Chain.fetch_last_token_balances() |
||||
|
||||
Task.start_link(fn -> |
||||
TokenBalanceOnDemand.trigger_fetch(address_hash, token_balances) |
||||
end) |
||||
|
||||
token_balances_with_price = |
||||
token_balances |
||||
|> Market.add_price() |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:token_balances, %{token_balances: token_balances_with_price}) |
||||
end |
||||
end |
||||
|
||||
def transactions(conn, %{"address_hash" => address_hash_string} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
options = |
||||
@transaction_necessity_by_association |
||||
|> Keyword.merge(paging_options(params)) |
||||
|> Keyword.merge(current_filter(params)) |
||||
|
||||
results_plus_one = Chain.address_to_transactions_with_rewards(address_hash, options) |
||||
{transactions, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, transactions, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:transactions, %{transactions: transactions, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def token_transfers(conn, %{"address_hash" => address_hash_string} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
options = |
||||
@transaction_with_tt_necessity_by_association |
||||
|> Keyword.merge(paging_options(params)) |
||||
|> Keyword.merge(current_filter(params)) |
||||
|
||||
results_plus_one = |
||||
Chain.address_hash_to_token_transfers( |
||||
address_hash, |
||||
options |
||||
) |
||||
|
||||
{transactions, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, transactions, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:transactions, %{transactions: transactions, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def internal_transactions(conn, %{"address_hash" => address_hash_string} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
full_options = |
||||
[ |
||||
necessity_by_association: %{ |
||||
[created_contract_address: :names] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
[created_contract_address: :smart_contract] => :optional, |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional |
||||
} |
||||
] |
||||
|> Keyword.merge(paging_options(params)) |
||||
|> Keyword.merge(current_filter(params)) |
||||
|
||||
results_plus_one = Chain.address_to_internal_transactions(address_hash, full_options) |
||||
{internal_transactions, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, internal_transactions, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:internal_transactions, %{ |
||||
internal_transactions: internal_transactions, |
||||
next_page_params: next_page_params |
||||
}) |
||||
end |
||||
end |
||||
|
||||
def logs(conn, %{"address_hash" => address_hash_string, "topic" => topic} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
prepared_topic = String.trim(topic) |
||||
|
||||
formatted_topic = if String.starts_with?(prepared_topic, "0x"), do: prepared_topic, else: "0x" <> prepared_topic |
||||
|
||||
results_plus_one = Chain.address_to_logs(address_hash, topic: formatted_topic) |
||||
|
||||
{logs, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, logs, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:logs, %{logs: logs, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def logs(conn, %{"address_hash" => address_hash_string} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
results_plus_one = Chain.address_to_logs(address_hash, paging_options(params)) |
||||
{logs, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, logs, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:logs, %{logs: logs, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def blocks_validated(conn, %{"address_hash" => address_hash_string} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
full_options = |
||||
Keyword.merge( |
||||
[ |
||||
necessity_by_association: %{ |
||||
miner: :required, |
||||
nephews: :optional, |
||||
transactions: :optional, |
||||
rewards: :optional |
||||
} |
||||
], |
||||
paging_options(params) |
||||
) |
||||
|
||||
results_plus_one = Chain.get_blocks_validated_by_address(full_options, address_hash) |
||||
{blocks, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, blocks, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(BlockView) |
||||
|> render(:blocks, %{blocks: blocks, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def coin_balance_history(conn, %{"address_hash" => address_hash_string} = params) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
full_options = paging_options(params) |
||||
|
||||
results_plus_one = Chain.address_to_coin_balances(address_hash, full_options) |
||||
|
||||
{coin_balances, next_page} = split_list_by_page(results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, coin_balances, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(AddressView) |
||||
|> render(:coin_balances, %{coin_balances: coin_balances, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def coin_balance_history_by_day(conn, %{"address_hash" => address_hash_string}) do |
||||
with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)} do |
||||
balances_by_day = |
||||
address_hash |
||||
|> Chain.address_to_balances_by_day(true) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(AddressView) |
||||
|> render(:coin_balances_by_day, %{coin_balances_by_day: balances_by_day}) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,81 @@ |
||||
defmodule BlockScoutWeb.API.V2.BlockController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
import BlockScoutWeb.Chain, |
||||
only: [next_page_params: 3, paging_options: 1, put_key_value_to_paging_options: 3, split_list_by_page: 1] |
||||
|
||||
import BlockScoutWeb.PagingHelper, only: [select_block_type: 1] |
||||
|
||||
alias BlockScoutWeb.API.V2.TransactionView |
||||
alias BlockScoutWeb.BlockTransactionController |
||||
alias Explorer.Chain |
||||
|
||||
@transaction_necessity_by_association [ |
||||
necessity_by_association: %{ |
||||
[created_contract_address: :names] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
:block => :optional, |
||||
[created_contract_address: :smart_contract] => :optional, |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional |
||||
} |
||||
] |
||||
|
||||
action_fallback(BlockScoutWeb.API.V2.FallbackController) |
||||
|
||||
def block(conn, %{"block_hash_or_number" => block_hash_or_number}) do |
||||
with {:ok, block} <- |
||||
BlockTransactionController.param_block_hash_or_number_to_block(block_hash_or_number, |
||||
necessity_by_association: %{ |
||||
[miner: :names] => :required, |
||||
:uncles => :optional, |
||||
:nephews => :optional, |
||||
:rewards => :optional, |
||||
:transactions => :optional |
||||
} |
||||
) do |
||||
conn |
||||
|> put_status(200) |
||||
|> render(:block, %{block: block}) |
||||
end |
||||
end |
||||
|
||||
def blocks(conn, params) do |
||||
full_options = select_block_type(params) |
||||
|
||||
blocks_plus_one = |
||||
full_options |
||||
|> Keyword.merge(paging_options(params)) |
||||
|> Chain.list_blocks() |
||||
|
||||
{blocks, next_page} = split_list_by_page(blocks_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, blocks, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:blocks, %{blocks: blocks, next_page_params: next_page_params}) |
||||
end |
||||
|
||||
def transactions(conn, %{"block_hash_or_number" => block_hash_or_number} = params) do |
||||
with {:ok, block} <- BlockTransactionController.param_block_hash_or_number_to_block(block_hash_or_number, []) do |
||||
full_options = |
||||
Keyword.merge( |
||||
@transaction_necessity_by_association, |
||||
put_key_value_to_paging_options(paging_options(params), :is_index_in_asc_order, true) |
||||
) |
||||
|
||||
transactions_plus_one = Chain.block_to_transactions(block.hash, full_options, false) |
||||
|
||||
{transactions, next_page} = split_list_by_page(transactions_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, transactions, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:transactions, %{transactions: transactions, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,11 @@ |
||||
defmodule BlockScoutWeb.API.V2.ConfigController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
def json_rpc_url(conn, _params) do |
||||
json_rpc_url = Application.get_env(:block_scout_web, :json_rpc) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:json_rpc_url, %{url: json_rpc_url}) |
||||
end |
||||
end |
@ -0,0 +1,38 @@ |
||||
defmodule BlockScoutWeb.API.V2.FallbackController do |
||||
use Phoenix.Controller |
||||
|
||||
alias BlockScoutWeb.API.V2.ApiView |
||||
|
||||
def call(conn, {:format, _}) do |
||||
conn |
||||
|> put_status(:unprocessable_entity) |
||||
|> put_view(ApiView) |
||||
|> render(:message, %{message: "Invalid parameter(s)"}) |
||||
end |
||||
|
||||
def call(conn, {:not_found, _}) do |
||||
conn |
||||
|> put_status(:not_found) |
||||
|> put_view(ApiView) |
||||
|> render(:message, %{message: "Not found"}) |
||||
end |
||||
|
||||
def call(conn, {:error, {:invalid, :hash}}) do |
||||
conn |
||||
|> put_status(:unprocessable_entity) |
||||
|> put_view(ApiView) |
||||
|> render(:message, %{message: "Invalid hash"}) |
||||
end |
||||
|
||||
def call(conn, {:error, {:invalid, :number}}) do |
||||
conn |
||||
|> put_status(:unprocessable_entity) |
||||
|> put_view(ApiView) |
||||
|> render(:message, %{message: "Invalid number"}) |
||||
end |
||||
|
||||
def call(conn, {:error, :not_found}) do |
||||
conn |
||||
|> call({:not_found, nil}) |
||||
end |
||||
end |
@ -0,0 +1,40 @@ |
||||
defmodule BlockScoutWeb.API.V2.MainPageController do |
||||
use Phoenix.Controller |
||||
|
||||
alias Explorer.{Chain, PagingOptions} |
||||
alias BlockScoutWeb.API.V2.{BlockView, TransactionView} |
||||
alias Explorer.{Chain, Repo} |
||||
|
||||
def blocks(conn, _params) do |
||||
blocks = |
||||
[paging_options: %PagingOptions{page_size: 4}] |
||||
|> Chain.list_blocks() |
||||
|> Repo.preload([[miner: :names], :transactions, :rewards]) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(BlockView) |
||||
|> render(:blocks, %{blocks: blocks}) |
||||
end |
||||
|
||||
def transactions(conn, _params) do |
||||
recent_transactions = |
||||
Chain.recent_collated_transactions(false, |
||||
necessity_by_association: %{ |
||||
:block => :required, |
||||
[created_contract_address: :names] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
[created_contract_address: :smart_contract] => :optional, |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional |
||||
}, |
||||
paging_options: %PagingOptions{page_size: 5} |
||||
) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> put_view(TransactionView) |
||||
|> render(:transactions, %{transactions: recent_transactions}) |
||||
end |
||||
end |
@ -0,0 +1,24 @@ |
||||
defmodule BlockScoutWeb.API.V2.SearchController do |
||||
use Phoenix.Controller |
||||
|
||||
import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] |
||||
|
||||
alias Explorer.Chain |
||||
|
||||
def search(conn, %{"q" => query} = params) do |
||||
[paging_options: paging_options] = paging_options(params) |
||||
offset = (max(paging_options.page_number, 1) - 1) * paging_options.page_size |
||||
|
||||
search_results_plus_one = |
||||
paging_options |
||||
|> Chain.joint_search(offset, query) |
||||
|
||||
{search_results, next_page} = split_list_by_page(search_results_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, search_results, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:search_results, %{search_results: search_results, next_page_params: next_page_params}) |
||||
end |
||||
end |
@ -0,0 +1,104 @@ |
||||
defmodule BlockScoutWeb.API.V2.StatsController do |
||||
use Phoenix.Controller |
||||
|
||||
alias BlockScoutWeb.API.V2.Helper |
||||
alias Explorer.{Chain, Market} |
||||
alias Explorer.Chain.Cache.Block, as: BlockCache |
||||
alias Explorer.Chain.Cache.{GasPriceOracle, GasUsage} |
||||
alias Explorer.Chain.Cache.Transaction, as: TransactionCache |
||||
alias Explorer.Chain.Supply.RSK |
||||
alias Explorer.Chain.Transaction.History.TransactionStats |
||||
alias Explorer.Counters.AverageBlockTime |
||||
alias Explorer.ExchangeRates.Token |
||||
alias Timex.Duration |
||||
|
||||
def stats(conn, _params) do |
||||
market_cap_type = |
||||
case Application.get_env(:explorer, :supply) do |
||||
RSK -> |
||||
RSK |
||||
|
||||
_ -> |
||||
:standard |
||||
end |
||||
|
||||
exchange_rate = Market.get_exchange_rate(Explorer.coin()) || Token.null() |
||||
|
||||
transaction_stats = Helper.get_transaction_stats() |
||||
|
||||
gas_prices = |
||||
case GasPriceOracle.get_gas_prices() do |
||||
{:ok, gas_prices} -> |
||||
gas_prices |
||||
|
||||
_ -> |
||||
nil |
||||
end |
||||
|
||||
gas_price = Application.get_env(:block_scout_web, :gas_price) |
||||
|
||||
json( |
||||
conn, |
||||
%{ |
||||
"total_blocks" => BlockCache.estimated_count() |> to_string(), |
||||
"total_addresses" => Chain.address_estimated_count() |> to_string(), |
||||
"total_transactions" => TransactionCache.estimated_count() |> to_string(), |
||||
"average_block_time" => AverageBlockTime.average_block_time() |> Duration.to_milliseconds(), |
||||
"coin_price" => exchange_rate.usd_value, |
||||
"total_gas_used" => GasUsage.total() |> to_string(), |
||||
"transactions_today" => Enum.at(transaction_stats, 0).number_of_transactions |> to_string(), |
||||
"gas_used_today" => Enum.at(transaction_stats, 0).gas_used, |
||||
"gas_prices" => gas_prices, |
||||
"static_gas_price" => gas_price, |
||||
"market_cap" => Helper.market_cap(market_cap_type, exchange_rate) |
||||
} |
||||
) |
||||
end |
||||
|
||||
def transactions_chart(conn, _params) do |
||||
[{:history_size, history_size}] = |
||||
Application.get_env(:block_scout_web, BlockScoutWeb.Chain.TransactionHistoryChartController, [{:history_size, 30}]) |
||||
|
||||
today = Date.utc_today() |
||||
latest = Date.add(today, -1) |
||||
earliest = Date.add(latest, -1 * history_size) |
||||
|
||||
date_range = TransactionStats.by_date_range(earliest, latest) |
||||
|
||||
transaction_history_data = |
||||
date_range |
||||
|> Enum.map(fn row -> |
||||
%{date: row.date, tx_count: row.number_of_transactions} |
||||
end) |
||||
|
||||
json(conn, %{ |
||||
chart_data: transaction_history_data |
||||
}) |
||||
end |
||||
|
||||
def market_chart(conn, _params) do |
||||
exchange_rate = Market.get_exchange_rate(Explorer.coin()) || Token.null() |
||||
|
||||
recent_market_history = Market.fetch_recent_history() |
||||
|
||||
market_history_data = |
||||
recent_market_history |
||||
|> case do |
||||
[today | the_rest] -> |
||||
[%{today | closing_price: exchange_rate.usd_value} | the_rest] |
||||
|
||||
data -> |
||||
data |
||||
end |
||||
|> Enum.map(fn day -> Map.take(day, [:closing_price, :date]) end) |
||||
|
||||
json(conn, %{ |
||||
chart_data: market_history_data, |
||||
available_supply: available_supply(Chain.supply_for_days(), exchange_rate) |
||||
}) |
||||
end |
||||
|
||||
defp available_supply(:ok, exchange_rate), do: exchange_rate.available_supply || 0 |
||||
|
||||
defp available_supply({:ok, supply_for_days}, _exchange_rate), do: supply_for_days |
||||
end |
@ -0,0 +1,221 @@ |
||||
defmodule BlockScoutWeb.API.V2.TransactionController do |
||||
use BlockScoutWeb, :controller |
||||
|
||||
import BlockScoutWeb.Chain, only: [next_page_params: 3, paging_options: 1, split_list_by_page: 1] |
||||
|
||||
import BlockScoutWeb.PagingHelper, |
||||
only: [paging_options: 2, filter_options: 1, method_filter_options: 1, type_filter_options: 1] |
||||
|
||||
alias Explorer.Chain |
||||
alias Explorer.Chain.Import |
||||
alias Explorer.Chain.Import.Runner.InternalTransactions |
||||
|
||||
action_fallback(BlockScoutWeb.API.V2.FallbackController) |
||||
|
||||
@transaction_necessity_by_association %{ |
||||
:block => :optional, |
||||
[created_contract_address: :names] => :optional, |
||||
[created_contract_address: :token] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
[to_address: :smart_contract] => :optional |
||||
} |
||||
|
||||
@token_transfers_neccessity_by_association %{ |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
from_address: :required, |
||||
to_address: :required, |
||||
token: :required |
||||
} |
||||
|
||||
@internal_transaction_neccessity_by_association [ |
||||
necessity_by_association: %{ |
||||
[created_contract_address: :names] => :optional, |
||||
[from_address: :names] => :optional, |
||||
[to_address: :names] => :optional, |
||||
[transaction: :block] => :optional, |
||||
[created_contract_address: :smart_contract] => :optional, |
||||
[from_address: :smart_contract] => :optional, |
||||
[to_address: :smart_contract] => :optional |
||||
} |
||||
] |
||||
|
||||
def transaction(conn, %{"transaction_hash" => transaction_hash_string}) do |
||||
with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash_string)}, |
||||
{:not_found, {:ok, transaction}} <- |
||||
{:not_found, |
||||
Chain.hash_to_transaction( |
||||
transaction_hash, |
||||
necessity_by_association: @transaction_necessity_by_association |
||||
)}, |
||||
preloaded <- Chain.preload_token_transfers(transaction, @token_transfers_neccessity_by_association, false) do |
||||
conn |
||||
|> put_status(200) |
||||
|> render(:transaction, %{transaction: preloaded}) |
||||
end |
||||
end |
||||
|
||||
def transactions(conn, params) do |
||||
filter_options = filter_options(params) |
||||
method_filter_options = method_filter_options(params) |
||||
type_filter_options = type_filter_options(params) |
||||
|
||||
full_options = |
||||
Keyword.merge( |
||||
[ |
||||
necessity_by_association: @transaction_necessity_by_association |
||||
], |
||||
paging_options(params, filter_options) |
||||
) |
||||
|
||||
transactions_plus_one = |
||||
Chain.recent_transactions(full_options, filter_options, method_filter_options, type_filter_options) |
||||
|
||||
{transactions, next_page} = split_list_by_page(transactions_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, transactions, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:transactions, %{transactions: transactions, next_page_params: next_page_params}) |
||||
end |
||||
|
||||
def raw_trace(conn, %{"transaction_hash" => transaction_hash_string}) do |
||||
with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash_string)}, |
||||
{:not_found, {:ok, transaction}} <- |
||||
{:not_found, Chain.hash_to_transaction(transaction_hash)} do |
||||
if is_nil(transaction.block_number) do |
||||
conn |
||||
|> put_status(200) |
||||
|> render(:raw_trace, %{internal_transactions: []}) |
||||
else |
||||
internal_transactions = Chain.all_transaction_to_internal_transactions(transaction_hash) |
||||
|
||||
first_trace_exists = |
||||
Enum.find_index(internal_transactions, fn trace -> |
||||
trace.index == 0 |
||||
end) |
||||
|
||||
json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) |
||||
|
||||
internal_transactions = |
||||
if first_trace_exists do |
||||
internal_transactions |
||||
else |
||||
response = |
||||
Chain.fetch_first_trace( |
||||
[ |
||||
%{ |
||||
block_hash: transaction.block_hash, |
||||
block_number: transaction.block_number, |
||||
hash_data: transaction_hash_string, |
||||
transaction_index: transaction.index |
||||
} |
||||
], |
||||
json_rpc_named_arguments |
||||
) |
||||
|
||||
case response do |
||||
{:ok, first_trace_params} -> |
||||
InternalTransactions.run_insert_only(first_trace_params, %{ |
||||
timeout: :infinity, |
||||
timestamps: Import.timestamps(), |
||||
internal_transactions: %{params: first_trace_params} |
||||
}) |
||||
|
||||
Chain.all_transaction_to_internal_transactions(transaction_hash) |
||||
|
||||
{:error, _} -> |
||||
internal_transactions |
||||
|
||||
:ignore -> |
||||
internal_transactions |
||||
end |
||||
end |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:raw_trace, %{internal_transactions: internal_transactions}) |
||||
end |
||||
end |
||||
end |
||||
|
||||
def token_transfers(conn, %{"transaction_hash" => transaction_hash_string} = params) do |
||||
with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash_string)} do |
||||
full_options = |
||||
Keyword.merge( |
||||
[ |
||||
necessity_by_association: @token_transfers_neccessity_by_association |
||||
], |
||||
paging_options(params) |
||||
) |
||||
|
||||
token_transfers_plus_one = Chain.transaction_to_token_transfers(transaction_hash, full_options) |
||||
|
||||
{token_transfers, next_page} = split_list_by_page(token_transfers_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, token_transfers, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:token_transfers, %{token_transfers: token_transfers, next_page_params: next_page_params}) |
||||
end |
||||
end |
||||
|
||||
def internal_transactions(conn, %{"transaction_hash" => transaction_hash_string} = params) do |
||||
with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash_string)} do |
||||
full_options = |
||||
Keyword.merge( |
||||
@internal_transaction_neccessity_by_association, |
||||
paging_options(params) |
||||
) |
||||
|
||||
internal_transactions_plus_one = Chain.transaction_to_internal_transactions(transaction_hash, full_options) |
||||
|
||||
{internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, internal_transactions, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:internal_transactions, %{ |
||||
internal_transactions: internal_transactions, |
||||
next_page_params: next_page_params |
||||
}) |
||||
end |
||||
end |
||||
|
||||
def logs(conn, %{"transaction_hash" => transaction_hash_string} = params) do |
||||
with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash_string)} do |
||||
full_options = |
||||
Keyword.merge( |
||||
[ |
||||
necessity_by_association: %{ |
||||
[address: :names] => :optional, |
||||
[address: :smart_contract] => :optional, |
||||
address: :optional |
||||
} |
||||
], |
||||
paging_options(params) |
||||
) |
||||
|
||||
from_api = true |
||||
logs_plus_one = Chain.transaction_to_logs(transaction_hash, from_api, full_options) |
||||
|
||||
{logs, next_page} = split_list_by_page(logs_plus_one) |
||||
|
||||
next_page_params = next_page_params(next_page, logs, params) |
||||
|
||||
conn |
||||
|> put_status(200) |
||||
|> render(:logs, %{ |
||||
tx_hash: transaction_hash, |
||||
logs: logs, |
||||
next_page_params: next_page_params |
||||
}) |
||||
end |
||||
end |
||||
end |
@ -1,20 +0,0 @@ |
||||
defmodule BlockScoutWeb.PaginationHelpers do |
||||
@moduledoc """ |
||||
Common pagination logic helpers. |
||||
""" |
||||
|
||||
def current_page_number(params) do |
||||
cond do |
||||
!params["prev_page_number"] -> 1 |
||||
params["next_page"] -> String.to_integer(params["prev_page_number"]) + 1 |
||||
params["prev_page"] -> String.to_integer(params["prev_page_number"]) - 1 |
||||
end |
||||
end |
||||
|
||||
def add_navigation_params(params, current_page_path, current_page_number) do |
||||
params |
||||
|> Map.put("prev_page_path", current_page_path) |
||||
|> Map.put("next_page", true) |
||||
|> Map.put("prev_page_number", current_page_number) |
||||
end |
||||
end |
@ -0,0 +1,117 @@ |
||||
defmodule BlockScoutWeb.PagingHelper do |
||||
@moduledoc """ |
||||
Helper for fetching filters and other url query paramters |
||||
""" |
||||
import Explorer.Chain, only: [string_to_transaction_hash: 1] |
||||
alias Explorer.PagingOptions |
||||
|
||||
@page_size 50 |
||||
@default_paging_options %PagingOptions{page_size: @page_size + 1} |
||||
@allowed_filter_labels ["validated", "pending"] |
||||
@allowed_type_labels ["coin_transfer", "contract_call", "contract_creation", "token_transfer", "token_creation"] |
||||
|
||||
def paging_options(%{"block_number" => block_number_string, "index" => index_string}, [:validated | _]) do |
||||
with {block_number, ""} <- Integer.parse(block_number_string), |
||||
{index, ""} <- Integer.parse(index_string) do |
||||
[paging_options: %{@default_paging_options | key: {block_number, index}}] |
||||
else |
||||
_ -> |
||||
[paging_options: @default_paging_options] |
||||
end |
||||
end |
||||
|
||||
def paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}, [:pending | _]) do |
||||
with {:ok, inserted_at, _} <- DateTime.from_iso8601(inserted_at_string), |
||||
{:ok, hash} <- string_to_transaction_hash(hash_string) do |
||||
[paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_tx: true}] |
||||
else |
||||
_ -> |
||||
[paging_options: @default_paging_options] |
||||
end |
||||
end |
||||
|
||||
def paging_options(_params, _filter), do: [paging_options: @default_paging_options] |
||||
|
||||
def filter_options(%{"filter" => filter}) do |
||||
parse_filter(filter, @allowed_filter_labels) |
||||
end |
||||
|
||||
def filter_options(_params), do: [] |
||||
|
||||
def type_filter_options(%{"type" => type}) do |
||||
parse_filter(type, @allowed_type_labels) |
||||
end |
||||
|
||||
def type_filter_options(_params), do: [] |
||||
|
||||
def method_filter_options(%{"method" => method}) do |
||||
parse_method_filter(method) |
||||
end |
||||
|
||||
def method_filter_options(_params), do: [] |
||||
|
||||
def parse_filter("[" <> filter, allowed_labels) do |
||||
filter |
||||
|> String.trim_trailing("]") |
||||
|> parse_filter(allowed_labels) |
||||
end |
||||
|
||||
# sobelow_skip ["DOS.StringToAtom"] |
||||
def parse_filter(filter, allowed_labels) when is_binary(filter) do |
||||
filter |
||||
|> String.split(",") |
||||
|> Enum.filter(fn label -> Enum.member?(allowed_labels, label) end) |
||||
|> Enum.uniq() |
||||
|> Enum.map(&String.to_atom/1) |
||||
end |
||||
|
||||
def parse_method_filter("[" <> filter) do |
||||
filter |
||||
|> String.trim_trailing("]") |
||||
|> parse_method_filter() |
||||
end |
||||
|
||||
def parse_method_filter(filter) do |
||||
filter |
||||
|> String.split(",") |
||||
|> Enum.uniq() |
||||
end |
||||
|
||||
def select_block_type(%{"type" => type}) do |
||||
case String.downcase(type) do |
||||
"uncle" -> |
||||
[ |
||||
necessity_by_association: %{ |
||||
:transactions => :optional, |
||||
[miner: :names] => :optional, |
||||
:nephews => :required, |
||||
:rewards => :optional |
||||
}, |
||||
block_type: "Uncle" |
||||
] |
||||
|
||||
"reorg" -> |
||||
[ |
||||
necessity_by_association: %{ |
||||
:transactions => :optional, |
||||
[miner: :names] => :optional, |
||||
:rewards => :optional |
||||
}, |
||||
block_type: "Reorg" |
||||
] |
||||
|
||||
_ -> |
||||
select_block_type(nil) |
||||
end |
||||
end |
||||
|
||||
def select_block_type(_), |
||||
do: [ |
||||
necessity_by_association: %{ |
||||
:transactions => :optional, |
||||
[miner: :names] => :optional, |
||||
:rewards => :optional |
||||
}, |
||||
block_type: "Block" |
||||
] |
||||
end |
@ -0,0 +1,21 @@ |
||||
defmodule BlockScoutWeb.Plug.CheckApiV2 do |
||||
@moduledoc """ |
||||
Checks if the API V2 enabled. |
||||
""" |
||||
import Plug.Conn |
||||
|
||||
alias BlockScoutWeb.API.V2, as: API_V2 |
||||
|
||||
def init(opts), do: opts |
||||
|
||||
def call(conn, _opts) do |
||||
if API_V2.enabled?() do |
||||
conn |
||||
else |
||||
conn |
||||
|> put_resp_content_type("application/json") |
||||
|> send_resp(404, Jason.encode!(%{message: "API V2 is disabled"})) |
||||
|> halt() |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,59 @@ |
||||
defmodule BlockScoutWeb.API.V2.AddressView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.API.V2.{ApiView, Helper, TokenView} |
||||
alias BlockScoutWeb.API.V2.Helper |
||||
|
||||
def render("message.json", assigns) do |
||||
ApiView.render("message.json", assigns) |
||||
end |
||||
|
||||
def render("address.json", %{address: address, conn: conn}) do |
||||
prepare_address(address, conn) |
||||
end |
||||
|
||||
def render("token_balances.json", %{token_balances: token_balances}) do |
||||
Enum.map(token_balances, &prepare_token_balance/1) |
||||
end |
||||
|
||||
def render("coin_balance.json", %{coin_balance: coin_balance}) do |
||||
prepare_coin_balance_history_entry(coin_balance) |
||||
end |
||||
|
||||
def render("coin_balances.json", %{coin_balances: coin_balances, next_page_params: next_page_params}) do |
||||
%{"items" => Enum.map(coin_balances, &prepare_coin_balance_history_entry/1), "next_page_params" => next_page_params} |
||||
end |
||||
|
||||
def render("coin_balances_by_day.json", %{coin_balances_by_day: coin_balances_by_day}) do |
||||
Enum.map(coin_balances_by_day, &prepare_coin_balance_history_by_day_entry/1) |
||||
end |
||||
|
||||
def prepare_address(address, conn \\ nil) do |
||||
Helper.address_with_info(conn, address, address.hash) |
||||
end |
||||
|
||||
def prepare_token_balance({token_balance, token}) do |
||||
%{ |
||||
"value" => token_balance.value, |
||||
"token" => TokenView.render("token.json", %{token: token}), |
||||
"token_id" => token_balance.token_id |
||||
} |
||||
end |
||||
|
||||
def prepare_coin_balance_history_entry(coin_balance) do |
||||
%{ |
||||
"transaction_hash" => coin_balance.transaction_hash, |
||||
"block_number" => coin_balance.block_number, |
||||
"delta" => coin_balance.delta, |
||||
"value" => coin_balance.value, |
||||
"block_timestamp" => coin_balance.block_timestamp |
||||
} |
||||
end |
||||
|
||||
def prepare_coin_balance_history_by_day_entry(coin_balance_by_day) do |
||||
%{ |
||||
"date" => coin_balance_by_day.date, |
||||
"value" => coin_balance_by_day.value |
||||
} |
||||
end |
||||
end |
@ -0,0 +1,9 @@ |
||||
defmodule BlockScoutWeb.API.V2 do |
||||
@moduledoc """ |
||||
API V2 context |
||||
""" |
||||
|
||||
def enabled? do |
||||
Application.get_env(:block_scout_web, __MODULE__)[:enabled] |
||||
end |
||||
end |
@ -0,0 +1,7 @@ |
||||
defmodule BlockScoutWeb.API.V2.ApiView do |
||||
def render("message.json", %{message: message}) do |
||||
%{ |
||||
"message" => message |
||||
} |
||||
end |
||||
end |
@ -0,0 +1,106 @@ |
||||
defmodule BlockScoutWeb.API.V2.BlockView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.BlockView |
||||
alias BlockScoutWeb.API.V2.{ApiView, Helper} |
||||
alias Explorer.Chain |
||||
alias Explorer.Chain.Block |
||||
alias Explorer.Counters.BlockPriorityFeeCounter |
||||
|
||||
def render("message.json", assigns) do |
||||
ApiView.render("message.json", assigns) |
||||
end |
||||
|
||||
def render("blocks.json", %{blocks: blocks, next_page_params: next_page_params}) do |
||||
%{"items" => Enum.map(blocks, &prepare_block(&1, nil)), "next_page_params" => next_page_params} |
||||
end |
||||
|
||||
def render("blocks.json", %{blocks: blocks}) do |
||||
Enum.map(blocks, &prepare_block(&1, nil)) |
||||
end |
||||
|
||||
def render("block.json", %{block: block, conn: conn}) do |
||||
prepare_block(block, conn, true) |
||||
end |
||||
|
||||
def render("block.json", %{block: block, socket: _socket}) do |
||||
# single_block? set to true in order to prevent heavy fetching of reward type |
||||
prepare_block(block, nil, false) |
||||
end |
||||
|
||||
def prepare_block(block, conn, single_block? \\ false) do |
||||
burned_fee = Chain.burned_fees(block.transactions, block.base_fee_per_gas) |
||||
priority_fee = block.base_fee_per_gas && BlockPriorityFeeCounter.fetch(block.hash) |
||||
|
||||
tx_fees = Chain.txn_fees(block.transactions) |
||||
|
||||
%{ |
||||
"height" => block.number, |
||||
"timestamp" => block.timestamp, |
||||
"tx_count" => count_transactions(block), |
||||
"miner" => Helper.address_with_info(conn, block.miner, block.miner_hash), |
||||
"size" => block.size, |
||||
"hash" => block.hash, |
||||
"parent_hash" => block.parent_hash, |
||||
"difficulty" => block.difficulty, |
||||
"total_difficulty" => block.total_difficulty, |
||||
"gas_used" => block.gas_used, |
||||
"gas_limit" => block.gas_limit, |
||||
"nonce" => block.nonce, |
||||
"base_fee_per_gas" => block.base_fee_per_gas, |
||||
"burnt_fees" => burned_fee, |
||||
"priority_fee" => priority_fee, |
||||
"extra_data" => "TODO", |
||||
"uncles_hashes" => prepare_uncles(block.uncle_relations), |
||||
"state_root" => "TODO", |
||||
"rewards" => prepare_rewards(block.rewards, block, single_block?), |
||||
"gas_target_percentage" => gas_target(block), |
||||
"gas_used_percentage" => gas_used_percentage(block), |
||||
"burnt_fees_percentage" => burnt_fees_percentage(burned_fee, tx_fees), |
||||
"type" => block |> BlockView.block_type() |> String.downcase(), |
||||
"tx_fees" => tx_fees |
||||
} |
||||
end |
||||
|
||||
def prepare_rewards(rewards, block, single_block?) do |
||||
Enum.map(rewards, &prepare_reward(&1, block, single_block?)) |
||||
end |
||||
|
||||
def prepare_reward(reward, block, single_block?) do |
||||
%{ |
||||
"reward" => reward.reward, |
||||
"type" => if(single_block?, do: BlockView.block_reward_text(reward, block.miner.hash), else: reward.address_type) |
||||
} |
||||
end |
||||
|
||||
def prepare_uncles(uncles_relations) when is_list(uncles_relations) do |
||||
Enum.map(uncles_relations, &prepare_uncle/1) |
||||
end |
||||
|
||||
def prepare_uncles(_), do: [] |
||||
|
||||
def prepare_uncle(uncle_relation) do |
||||
%{"hash" => uncle_relation.uncle_hash} |
||||
end |
||||
|
||||
def gas_target(block) do |
||||
elasticity_multiplier = 2 |
||||
ratio = Decimal.div(block.gas_used, Decimal.div(block.gas_limit, elasticity_multiplier)) |
||||
ratio |> Decimal.sub(1) |> Decimal.mult(100) |> Decimal.to_float() |
||||
end |
||||
|
||||
def gas_used_percentage(block) do |
||||
block.gas_used |> Decimal.div(block.gas_limit) |> Decimal.mult(100) |> Decimal.to_float() |
||||
end |
||||
|
||||
def burnt_fees_percentage(_, %Decimal{coef: 0}), do: nil |
||||
|
||||
def burnt_fees_percentage(burnt_fees, tx_fees) when not is_nil(tx_fees) and not is_nil(burnt_fees) do |
||||
burnt_fees.value |> Decimal.div(tx_fees) |> Decimal.mult(100) |> Decimal.to_float() |
||||
end |
||||
|
||||
def burnt_fees_percentage(_, _), do: nil |
||||
|
||||
def count_transactions(%Block{transactions: txs}) when is_list(txs), do: Enum.count(txs) |
||||
def count_transactions(_), do: nil |
||||
end |
@ -0,0 +1,7 @@ |
||||
defmodule BlockScoutWeb.API.V2.ConfigView do |
||||
def render("json_rpc_url.json", %{url: url}) do |
||||
%{ |
||||
"json_rpc_url" => url |
||||
} |
||||
end |
||||
end |
@ -0,0 +1,108 @@ |
||||
defmodule BlockScoutWeb.API.V2.Helper do |
||||
@moduledoc """ |
||||
API V2 helper |
||||
""" |
||||
|
||||
alias Ecto.Association.NotLoaded |
||||
alias Explorer.Chain.Address |
||||
alias Explorer.Chain.Transaction.History.TransactionStats |
||||
|
||||
import BlockScoutWeb.Account.AuthController, only: [current_user: 1] |
||||
import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2, get_tags_on_address: 1] |
||||
|
||||
def address_with_info(conn, address, address_hash) do |
||||
%{ |
||||
personal_tags: private_tags, |
||||
watchlist_names: watchlist_names |
||||
} = get_address_tags(address_hash, current_user(conn)) |
||||
|
||||
public_tags = get_tags_on_address(address_hash) |
||||
|
||||
Map.merge(address_with_info(address, address_hash), %{ |
||||
"private_tags" => private_tags, |
||||
"watchlist_names" => watchlist_names, |
||||
"public_tags" => public_tags |
||||
}) |
||||
end |
||||
|
||||
def address_with_info(%Address{} = address, _address_hash) do |
||||
%{ |
||||
"hash" => to_string(address), |
||||
"is_contract" => is_smart_contract(address), |
||||
"name" => address_name(address), |
||||
"implementation_name" => implementation_name(address), |
||||
"is_verified" => is_verified(address) |
||||
} |
||||
end |
||||
|
||||
def address_with_info(%NotLoaded{}, address_hash) do |
||||
address_with_info(nil, address_hash) |
||||
end |
||||
|
||||
def address_with_info(nil, address_hash) do |
||||
%{"hash" => address_hash, "is_contract" => false, "name" => nil, "implementation_name" => nil, "is_verified" => nil} |
||||
end |
||||
|
||||
def address_name(%Address{names: [_ | _] = address_names}) do |
||||
case Enum.find(address_names, &(&1.primary == true)) do |
||||
nil -> |
||||
%Address.Name{name: name} = Enum.at(address_names, 0) |
||||
name |
||||
|
||||
%Address.Name{name: name} -> |
||||
name |
||||
end |
||||
end |
||||
|
||||
def address_name(_), do: nil |
||||
|
||||
def implementation_name(%Address{smart_contract: %{implementation_name: implementation_name}}), |
||||
do: implementation_name |
||||
|
||||
def implementation_name(_), do: nil |
||||
|
||||
def is_smart_contract(%Address{contract_code: nil}), do: false |
||||
def is_smart_contract(%Address{contract_code: _}), do: true |
||||
def is_smart_contract(%NotLoaded{}), do: nil |
||||
def is_smart_contract(_), do: false |
||||
|
||||
def is_verified(%Address{smart_contract: nil}), do: false |
||||
def is_verified(%Address{smart_contract: %NotLoaded{}}), do: nil |
||||
def is_verified(%Address{smart_contract: _}), do: true |
||||
|
||||
def market_cap(:standard, %{available_supply: available_supply, usd_value: usd_value}) |
||||
when is_nil(available_supply) or is_nil(usd_value) do |
||||
Decimal.new(0) |
||||
end |
||||
|
||||
def market_cap(:standard, %{available_supply: available_supply, usd_value: usd_value}) do |
||||
Decimal.mult(available_supply, usd_value) |
||||
end |
||||
|
||||
def market_cap(:standard, exchange_rate) do |
||||
exchange_rate.market_cap_usd |
||||
end |
||||
|
||||
def market_cap(module, exchange_rate) do |
||||
module.market_cap(exchange_rate) |
||||
end |
||||
|
||||
def get_transaction_stats do |
||||
stats_scale = date_range(1) |
||||
transaction_stats = TransactionStats.by_date_range(stats_scale.earliest, stats_scale.latest) |
||||
|
||||
# Need datapoint for legend if none currently available. |
||||
if Enum.empty?(transaction_stats) do |
||||
[%{number_of_transactions: 0, gas_used: 0}] |
||||
else |
||||
transaction_stats |
||||
end |
||||
end |
||||
|
||||
def date_range(num_days) do |
||||
today = Date.utc_today() |
||||
latest = Date.add(today, -1) |
||||
x_days_back = Date.add(latest, -1 * (num_days - 1)) |
||||
%{earliest: x_days_back, latest: latest} |
||||
end |
||||
end |
@ -0,0 +1,53 @@ |
||||
defmodule BlockScoutWeb.API.V2.SearchView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.Endpoint |
||||
|
||||
def render("search_results.json", %{search_results: search_results, next_page_params: next_page_params}) do |
||||
%{"items" => Enum.map(search_results, &prepare_search_result/1), "next_page_params" => next_page_params} |
||||
end |
||||
|
||||
def prepare_search_result(%{type: "token"} = search_result) do |
||||
%{ |
||||
"type" => search_result.type, |
||||
"name" => search_result.name, |
||||
"symbol" => search_result.symbol, |
||||
"address" => search_result.address_hash, |
||||
"token_url" => token_path(Endpoint, :show, search_result.address_hash), |
||||
"address_url" => address_path(Endpoint, :show, search_result.address_hash) |
||||
} |
||||
end |
||||
|
||||
def prepare_search_result(%{type: address_or_contract} = search_result) |
||||
when address_or_contract in ["address", "contract"] do |
||||
%{ |
||||
"type" => search_result.type, |
||||
"name" => search_result.name, |
||||
"address" => search_result.address_hash, |
||||
"url" => address_path(Endpoint, :show, search_result.address_hash) |
||||
} |
||||
end |
||||
|
||||
def prepare_search_result(%{type: "block"} = search_result) do |
||||
block_hash = hash_to_string(search_result.block_hash) |
||||
|
||||
%{ |
||||
"type" => search_result.type, |
||||
"block_number" => search_result.block_number, |
||||
"block_hash" => block_hash, |
||||
"url" => block_path(Endpoint, :show, block_hash) |
||||
} |
||||
end |
||||
|
||||
def prepare_search_result(%{type: "transaction"} = search_result) do |
||||
tx_hash = hash_to_string(search_result.tx_hash) |
||||
|
||||
%{ |
||||
"type" => search_result.type, |
||||
"tx_hash" => tx_hash, |
||||
"url" => transaction_path(Endpoint, :show, tx_hash) |
||||
} |
||||
end |
||||
|
||||
defp hash_to_string(hash), do: "0x" <> Base.encode16(hash, case: :lower) |
||||
end |
@ -0,0 +1,13 @@ |
||||
defmodule BlockScoutWeb.API.V2.TokenView do |
||||
def render("token.json", %{token: token}) do |
||||
%{ |
||||
"address" => token.contract_address_hash, |
||||
"symbol" => token.symbol, |
||||
"name" => token.name, |
||||
"decimals" => token.decimals, |
||||
"type" => token.type, |
||||
"holders" => to_string(token.holder_count), |
||||
"exchange_rate" => token.usd_value && to_string(token.usd_value) |
||||
} |
||||
end |
||||
end |
@ -0,0 +1,447 @@ |
||||
defmodule BlockScoutWeb.API.V2.TransactionView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
alias BlockScoutWeb.API.V2.{ApiView, Helper, TokenView} |
||||
alias BlockScoutWeb.{ABIEncodedValueView, TransactionView} |
||||
alias BlockScoutWeb.Models.GetTransactionTags |
||||
alias BlockScoutWeb.Tokens.Helpers |
||||
alias Ecto.Association.NotLoaded |
||||
alias Explorer.ExchangeRates.Token, as: TokenRate |
||||
alias Explorer.{Chain, Market} |
||||
alias Explorer.Chain.{Address, Block, InternalTransaction, Log, Token, Transaction, Wei} |
||||
alias Explorer.Chain.Block.Reward |
||||
alias Explorer.Counters.AverageBlockTime |
||||
alias Timex.Duration |
||||
|
||||
import BlockScoutWeb.Account.AuthController, only: [current_user: 1] |
||||
|
||||
def render("message.json", assigns) do |
||||
ApiView.render("message.json", assigns) |
||||
end |
||||
|
||||
def render("transactions.json", %{transactions: transactions, conn: conn}) do |
||||
Enum.map(transactions, &prepare_transaction(&1, conn, false)) |
||||
end |
||||
|
||||
def render("transactions.json", %{transactions: transactions, next_page_params: next_page_params, conn: conn}) do |
||||
%{"items" => Enum.map(transactions, &prepare_transaction(&1, conn, false)), "next_page_params" => next_page_params} |
||||
end |
||||
|
||||
def render("transaction.json", %{transaction: transaction, conn: conn}) do |
||||
prepare_transaction(transaction, conn, true) |
||||
end |
||||
|
||||
def render("raw_trace.json", %{internal_transactions: internal_transactions}) do |
||||
InternalTransaction.internal_transactions_to_raw(internal_transactions) |
||||
end |
||||
|
||||
def render("decoded_log_input.json", %{method_id: method_id, text: text, mapping: mapping}) do |
||||
%{"method_id" => method_id, "method_call" => text, "parameters" => prepare_log_mapping(mapping)} |
||||
end |
||||
|
||||
def render("decoded_input.json", %{method_id: method_id, text: text, mapping: mapping, error?: _error}) do |
||||
%{"method_id" => method_id, "method_call" => text, "parameters" => prepare_method_mapping(mapping)} |
||||
end |
||||
|
||||
def render("revert_reason.json", %{raw: raw, decoded: decoded}) do |
||||
%{"raw" => raw, "decoded" => decoded} |
||||
end |
||||
|
||||
def render("token_transfers.json", %{token_transfers: token_transfers, next_page_params: next_page_params, conn: conn}) do |
||||
%{"items" => Enum.map(token_transfers, &prepare_token_transfer(&1, conn)), "next_page_params" => next_page_params} |
||||
end |
||||
|
||||
def render("token_transfers.json", %{token_transfers: token_transfers, conn: conn}) do |
||||
Enum.map(token_transfers, &prepare_token_transfer(&1, conn)) |
||||
end |
||||
|
||||
def render("token_transfer.json", %{token_transfer: token_transfer, conn: conn}) do |
||||
prepare_token_transfer(token_transfer, conn) |
||||
end |
||||
|
||||
def render("internal_transactions.json", %{ |
||||
internal_transactions: internal_transactions, |
||||
next_page_params: next_page_params, |
||||
conn: conn |
||||
}) do |
||||
%{ |
||||
"items" => Enum.map(internal_transactions, &prepare_internal_transaction(&1, conn)), |
||||
"next_page_params" => next_page_params |
||||
} |
||||
end |
||||
|
||||
def render("logs.json", %{logs: logs, next_page_params: next_page_params, tx_hash: tx_hash}) do |
||||
%{"items" => Enum.map(logs, fn log -> prepare_log(log, tx_hash) end), "next_page_params" => next_page_params} |
||||
end |
||||
|
||||
def render("logs.json", %{logs: logs, next_page_params: next_page_params}) do |
||||
%{ |
||||
"items" => Enum.map(logs, fn log -> prepare_log(log, log.transaction) end), |
||||
"next_page_params" => next_page_params |
||||
} |
||||
end |
||||
|
||||
def prepare_token_transfer(token_transfer, conn) do |
||||
%{ |
||||
"tx_hash" => token_transfer.transaction_hash, |
||||
"from" => Helper.address_with_info(conn, token_transfer.from_address, token_transfer.from_address_hash), |
||||
"to" => Helper.address_with_info(conn, token_transfer.to_address, token_transfer.to_address_hash), |
||||
"total" => prepare_token_transfer_total(token_transfer), |
||||
"token" => TokenView.render("token.json", %{token: Market.add_price(token_transfer.token)}), |
||||
"type" => Chain.get_token_transfer_type(token_transfer) |
||||
} |
||||
end |
||||
|
||||
def prepare_token_transfer_total(token_transfer) do |
||||
case Helpers.token_transfer_amount_for_api(token_transfer) do |
||||
{:ok, :erc721_instance} -> |
||||
%{"token_id" => token_transfer.token_id} |
||||
|
||||
{:ok, :erc1155_instance, value, decimals} -> |
||||
%{"token_id" => token_transfer.token_id, "value" => value, "decimals" => decimals} |
||||
|
||||
{:ok, :erc1155_instance, values, token_ids, decimals} -> |
||||
Enum.map(Enum.zip(values, token_ids), fn {value, token_id} -> |
||||
%{"value" => value, "token_id" => token_id, "decimals" => decimals} |
||||
end) |
||||
|
||||
{:ok, value, decimals} -> |
||||
%{"value" => value, "decimals" => decimals} |
||||
|
||||
_ -> |
||||
nil |
||||
end |
||||
end |
||||
|
||||
def prepare_internal_transaction(internal_transaction, conn) do |
||||
%{ |
||||
"error" => internal_transaction.error, |
||||
"success" => is_nil(internal_transaction.error), |
||||
"type" => internal_transaction.call_type, |
||||
"transaction_hash" => internal_transaction.transaction_hash, |
||||
"from" => |
||||
Helper.address_with_info( |
||||
conn, |
||||
internal_transaction.from_address, |
||||
internal_transaction.from_address_hash |
||||
), |
||||
"to" => Helper.address_with_info(conn, internal_transaction.to_address, internal_transaction.to_address_hash), |
||||
"created_contract" => |
||||
Helper.address_with_info( |
||||
conn, |
||||
internal_transaction.created_contract_address, |
||||
internal_transaction.created_contract_address_hash |
||||
), |
||||
"value" => internal_transaction.value, |
||||
"block" => internal_transaction.block_number, |
||||
"timestamp" => internal_transaction.transaction.block.timestamp, |
||||
"index" => internal_transaction.index, |
||||
"gas_limit" => internal_transaction.gas |
||||
} |
||||
end |
||||
|
||||
def prepare_log(log, transaction_or_hash) do |
||||
decoded = decode_log(log, transaction_or_hash) |
||||
|
||||
%{ |
||||
"address" => Helper.address_with_info(log.address, log.address_hash), |
||||
"topics" => [ |
||||
log.first_topic, |
||||
log.second_topic, |
||||
log.third_topic, |
||||
log.fourth_topic |
||||
], |
||||
"data" => log.data, |
||||
"index" => log.index, |
||||
"decoded" => decoded, |
||||
"smart_contract" => smart_contract_info(transaction_or_hash) |
||||
} |
||||
end |
||||
|
||||
defp smart_contract_info(%Transaction{} = tx), do: Helper.address_with_info(tx.to_address, tx.to_address_hash) |
||||
defp smart_contract_info(_), do: nil |
||||
|
||||
defp decode_log(log, %Transaction{} = tx) do |
||||
case log |> Log.decode(tx) |> format_decoded_log_input() do |
||||
{:ok, method_id, text, mapping} -> |
||||
render(__MODULE__, "decoded_log_input.json", method_id: method_id, text: text, mapping: mapping) |
||||
|
||||
_ -> |
||||
nil |
||||
end |
||||
end |
||||
|
||||
defp decode_log(log, transaction_hash), do: decode_log(log, %Transaction{hash: transaction_hash}) |
||||
|
||||
defp prepare_transaction({%Reward{} = emission_reward, %Reward{} = validator_reward}, conn, _single_tx?) do |
||||
%{ |
||||
"emission_reward" => emission_reward.reward, |
||||
"block_hash" => validator_reward.block_hash, |
||||
"from" => Helper.address_with_info(conn, emission_reward.address, emission_reward.address_hash), |
||||
"to" => Helper.address_with_info(conn, validator_reward.address, validator_reward.address_hash), |
||||
"types" => [:reward] |
||||
} |
||||
end |
||||
|
||||
defp prepare_transaction(%Transaction{} = transaction, conn, single_tx?) do |
||||
base_fee_per_gas = transaction.block && transaction.block.base_fee_per_gas |
||||
max_priority_fee_per_gas = transaction.max_priority_fee_per_gas |
||||
max_fee_per_gas = transaction.max_fee_per_gas |
||||
|
||||
priority_fee_per_gas = priority_fee_per_gas(max_priority_fee_per_gas, base_fee_per_gas, max_fee_per_gas) |
||||
|
||||
burned_fee = burned_fee(transaction, max_fee_per_gas, base_fee_per_gas) |
||||
|
||||
status = transaction |> Chain.transaction_to_status() |> format_status() |
||||
|
||||
revert_reason = revert_reason(status, transaction) |
||||
|
||||
decoded_input = transaction |> Transaction.decoded_input_data() |> format_decoded_input() |
||||
decoded_input_data = decoded_input(decoded_input) |
||||
|
||||
%{ |
||||
"hash" => transaction.hash, |
||||
"result" => status, |
||||
"status" => transaction.status, |
||||
"block" => transaction.block_number, |
||||
"timestamp" => transaction.block && transaction.block.timestamp, |
||||
"from" => Helper.address_with_info(conn, transaction.from_address, transaction.from_address_hash), |
||||
"to" => Helper.address_with_info(conn, transaction.to_address, transaction.to_address_hash), |
||||
"created_contract" => |
||||
Helper.address_with_info(conn, transaction.created_contract_address, transaction.created_contract_address_hash), |
||||
"confirmations" => |
||||
transaction.block |> Chain.confirmations(block_height: Chain.block_height()) |> format_confirmations(), |
||||
"confirmation_duration" => processing_time_duration(transaction), |
||||
"value" => transaction.value, |
||||
"fee" => transaction |> Chain.fee(:wei) |> format_fee(), |
||||
"gas_price" => transaction.gas_price, |
||||
"type" => transaction.type, |
||||
"gas_used" => transaction.gas_used, |
||||
"gas_limit" => transaction.gas, |
||||
"max_fee_per_gas" => transaction.max_fee_per_gas, |
||||
"max_priority_fee_per_gas" => transaction.max_priority_fee_per_gas, |
||||
"base_fee_per_gas" => base_fee_per_gas, |
||||
"priority_fee" => priority_fee_per_gas && Wei.mult(priority_fee_per_gas, transaction.gas_used), |
||||
"tx_burnt_fee" => burned_fee, |
||||
"nonce" => transaction.nonce, |
||||
"position" => transaction.index, |
||||
"revert_reason" => revert_reason, |
||||
"raw_input" => transaction.input, |
||||
"decoded_input" => decoded_input_data, |
||||
"token_transfers" => token_transfers(transaction.token_transfers, conn, single_tx?), |
||||
"token_transfers_overflow" => token_transfers_overflow(transaction.token_transfers, single_tx?), |
||||
"exchange_rate" => (Market.get_exchange_rate(Explorer.coin()) || TokenRate.null()).usd_value, |
||||
"method" => method_name(transaction, decoded_input), |
||||
"tx_types" => tx_types(transaction), |
||||
"tx_tag" => GetTransactionTags.get_transaction_tags(transaction.hash, current_user(conn)) |
||||
} |
||||
end |
||||
|
||||
def token_transfers(_, _conn, false), do: nil |
||||
def token_transfers(%NotLoaded{}, _conn, _), do: nil |
||||
|
||||
def token_transfers(token_transfers, conn, _) do |
||||
render("token_transfers.json", %{ |
||||
token_transfers: Enum.take(token_transfers, Chain.get_token_transfers_per_transaction_preview_count()), |
||||
conn: conn |
||||
}) |
||||
end |
||||
|
||||
def token_transfers_overflow(_, false), do: nil |
||||
def token_transfers_overflow(%NotLoaded{}, _), do: false |
||||
|
||||
def token_transfers_overflow(token_transfers, _), |
||||
do: Enum.count(token_transfers) > Chain.get_token_transfers_per_transaction_preview_count() |
||||
|
||||
defp priority_fee_per_gas(max_priority_fee_per_gas, base_fee_per_gas, max_fee_per_gas) do |
||||
if is_nil(max_priority_fee_per_gas) or is_nil(base_fee_per_gas), |
||||
do: nil, |
||||
else: |
||||
Enum.min_by([max_priority_fee_per_gas, Wei.sub(max_fee_per_gas, base_fee_per_gas)], fn x -> |
||||
Wei.to(x, :wei) |
||||
end) |
||||
end |
||||
|
||||
defp burned_fee(transaction, max_fee_per_gas, base_fee_per_gas) do |
||||
if !is_nil(max_fee_per_gas) and !is_nil(transaction.gas_used) and !is_nil(base_fee_per_gas) do |
||||
if Decimal.compare(max_fee_per_gas.value, 0) == :eq do |
||||
%Wei{value: Decimal.new(0)} |
||||
else |
||||
Wei.mult(base_fee_per_gas, transaction.gas_used) |
||||
end |
||||
else |
||||
nil |
||||
end |
||||
end |
||||
|
||||
defp revert_reason(status, transaction) do |
||||
if is_binary(status) && status |> String.downcase() |> String.contains?("reverted") do |
||||
case TransactionView.transaction_revert_reason(transaction) do |
||||
{:error, _contract_not_verified, candidates} when candidates != [] -> |
||||
{:ok, method_id, text, mapping} = Enum.at(candidates, 0) |
||||
render(__MODULE__, "decoded_input.json", method_id: method_id, text: text, mapping: mapping, error?: true) |
||||
|
||||
{:ok, method_id, text, mapping} -> |
||||
render(__MODULE__, "decoded_input.json", method_id: method_id, text: text, mapping: mapping, error?: true) |
||||
|
||||
_ -> |
||||
hex = TransactionView.get_pure_transaction_revert_reason(transaction) |
||||
utf8 = TransactionView.decoded_revert_reason(transaction) |
||||
render(__MODULE__, "revert_reason.json", raw: hex, decoded: utf8) |
||||
end |
||||
end |
||||
end |
||||
|
||||
defp decoded_input(decoded_input) do |
||||
case decoded_input do |
||||
{:ok, method_id, text, mapping} -> |
||||
render(__MODULE__, "decoded_input.json", method_id: method_id, text: text, mapping: mapping, error?: false) |
||||
|
||||
_ -> |
||||
nil |
||||
end |
||||
end |
||||
|
||||
def prepare_method_mapping(mapping) do |
||||
Enum.map(mapping, fn {name, type, value} -> |
||||
%{"name" => name, "type" => type, "value" => ABIEncodedValueView.value_json(type, value)} |
||||
end) |
||||
end |
||||
|
||||
def prepare_log_mapping(mapping) do |
||||
Enum.map(mapping, fn {name, type, indexed?, value} -> |
||||
%{"name" => name, "type" => type, "indexed" => indexed?, "value" => ABIEncodedValueView.value_json(type, value)} |
||||
end) |
||||
end |
||||
|
||||
defp format_status({:error, reason}), do: reason |
||||
defp format_status(status), do: status |
||||
|
||||
defp format_decoded_input({:error, _, []}), do: nil |
||||
defp format_decoded_input({:error, _, candidates}), do: Enum.at(candidates, 0) |
||||
defp format_decoded_input({:ok, _identifier, _text, _mapping} = decoded), do: decoded |
||||
defp format_decoded_input(_), do: nil |
||||
|
||||
defp format_decoded_log_input({:error, :could_not_decode}), do: nil |
||||
defp format_decoded_log_input({:error, :no_matching_function}), do: nil |
||||
defp format_decoded_log_input({:ok, _method_id, _text, _mapping} = decoded), do: decoded |
||||
defp format_decoded_log_input({:error, _, candidates}), do: Enum.at(candidates, 0) |
||||
|
||||
def format_confirmations({:ok, confirmations}), do: confirmations |
||||
def format_confirmations(_), do: 0 |
||||
|
||||
def format_fee({type, value}), do: %{"type" => type, "value" => value} |
||||
|
||||
def processing_time_duration(%Transaction{block: nil}) do |
||||
[] |
||||
end |
||||
|
||||
def processing_time_duration(%Transaction{earliest_processing_start: nil}) do |
||||
avg_time = AverageBlockTime.average_block_time() |
||||
|
||||
if avg_time == {:error, :disabled} do |
||||
[] |
||||
else |
||||
[ |
||||
0, |
||||
avg_time |
||||
|> Duration.to_milliseconds() |
||||
] |
||||
end |
||||
end |
||||
|
||||
def processing_time_duration(%Transaction{ |
||||
block: %Block{timestamp: end_time}, |
||||
earliest_processing_start: earliest_processing_start, |
||||
inserted_at: inserted_at |
||||
}) do |
||||
long_interval = abs(diff(earliest_processing_start, end_time)) |
||||
short_interval = abs(diff(inserted_at, end_time)) |
||||
merge_intervals(short_interval, long_interval) |
||||
end |
||||
|
||||
def merge_intervals(short, long) when short == long, do: [short] |
||||
|
||||
def merge_intervals(short, long) do |
||||
[short, long] |
||||
end |
||||
|
||||
def diff(left, right) do |
||||
left |
||||
|> Timex.diff(right, :milliseconds) |
||||
end |
||||
|
||||
defp method_name(_, {:ok, _method_id, text, _mapping}) do |
||||
Transaction.parse_method_name(text, false) |
||||
end |
||||
|
||||
defp method_name(%Transaction{to_address: to_address, input: %{bytes: <<method_id::binary-size(4), _::binary>>}}, _) do |
||||
if Helper.is_smart_contract(to_address) do |
||||
"0x" <> Base.encode16(method_id, case: :lower) |
||||
else |
||||
nil |
||||
end |
||||
end |
||||
|
||||
defp method_name(_, _) do |
||||
nil |
||||
end |
||||
|
||||
defp tx_types(tx, types \\ [], stage \\ :token_transfer) |
||||
|
||||
defp tx_types(%Transaction{token_transfers: token_transfers} = tx, types, :token_transfer) do |
||||
types = |
||||
if !is_nil(token_transfers) && token_transfers != [] && !match?(%NotLoaded{}, token_transfers) do |
||||
[:token_transfer | types] |
||||
else |
||||
types |
||||
end |
||||
|
||||
tx_types(tx, types, :token_creation) |
||||
end |
||||
|
||||
defp tx_types(%Transaction{created_contract_address: created_contract_address} = tx, types, :token_creation) do |
||||
types = |
||||
if match?(%Address{}, created_contract_address) && match?(%Token{}, created_contract_address.token) do |
||||
[:token_creation | types] |
||||
else |
||||
types |
||||
end |
||||
|
||||
tx_types(tx, types, :contract_creation) |
||||
end |
||||
|
||||
defp tx_types( |
||||
%Transaction{created_contract_address_hash: created_contract_address_hash} = tx, |
||||
types, |
||||
:contract_creation |
||||
) do |
||||
types = |
||||
if is_nil(created_contract_address_hash) do |
||||
types |
||||
else |
||||
[:contract_creation | types] |
||||
end |
||||
|
||||
tx_types(tx, types, :contract_call) |
||||
end |
||||
|
||||
defp tx_types(%Transaction{to_address: to_address} = tx, types, :contract_call) do |
||||
types = |
||||
if Helper.is_smart_contract(to_address) do |
||||
[:contract_call | types] |
||||
else |
||||
types |
||||
end |
||||
|
||||
tx_types(tx, types, :coin_transfer) |
||||
end |
||||
|
||||
defp tx_types(%Transaction{value: value}, types, :coin_transfer) do |
||||
if Decimal.compare(value.value, 0) == :gt do |
||||
[:coin_transfer | types] |
||||
else |
||||
types |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,15 @@ |
||||
defmodule Explorer.Repo.Migrations.AddMethodIdIndex do |
||||
use Ecto.Migration |
||||
|
||||
@disable_ddl_transaction true |
||||
|
||||
def up do |
||||
execute(""" |
||||
CREATE INDEX CONCURRENTLY method_id ON public.transactions USING btree (substring(input for 4)); |
||||
""") |
||||
end |
||||
|
||||
def down do |
||||
execute("DROP INDEX method_id") |
||||
end |
||||
end |
Loading…
Reference in new issue